Garantizar la seguridad y privacidad de los usuarios es más importante que nunca. La autenticación web juega un papel crucial en este aspecto, ya que sirve como primera línea de defensa para proteger la información y los datos de los usuarios.
Hoy en día contamos con herramientas como NextAuth.js que nos facilitan mucho el trabajo, permitiéndonos implementar diferentes tipos de autenticación fácilmente en nuestras aplicaciones Next.js.
En esta serie de tutoriales, vamos a construir un sistema de autenticación completo en Next.js 14, comenzando con lo básico: autenticación con correo electrónico y contraseña.
Dentro del ecosistema JavaScript y, más concretamente, en las aplicaciones desarrolladas con Next.js, una de las bibliotecas más destacadas para el manejo de la autenticación es NextAuth.js.
Esta herramienta ofrece una solución sencilla y fácil de implementar para añadir autenticación a nuestras aplicaciones. Lo mejor es su flexibilidad; permite la integración de diferentes proveedores de autenticación como Google, Facebook y Twitter, además de la autenticación basada en credenciales, como la clásica correo electrónico y contraseña.
La autenticación de credenciales es especialmente útil en aplicaciones en las que necesita control total sobre el proceso de autenticación y el almacenamiento de las credenciales del usuario, o cuando no desea depender de proveedores de autenticación externos.
src/
.
npx create-next-app@latest
2. Instale las dependencias necesarias en el proyecto. Esta vez usaremos pnpm
; puede utilizar el administrador de paquetes de su elección.
pnpm install next-auth prisma react-hook-form zod, bcrypt
Para la interfaz de usuario, usaremos Shadcn/ui.
pnpm dlx shadcn-ui@latest init
3. Cree la siguiente estructura para el proyecto:
... ├── prisma/ ... ├── src/ │ ├── actions/ │ │ └── auth-actions.tsx │ ├── app/ │ │ ├── api/auth/[...nextauth] │ │ │ └── route.ts │ │ ├── auth/ │ │ │ ├── signin │ │ │ │ └── page.tsx │ │ │ └── signup │ │ │ └── page.tsx │ │ │ ... │ ├── components/ │ │ ├── auth/ │ │ │ ├── auth-buttons.tsx │ │ │ ├── signin-form.tsx │ │ │ ├── signup-form.tsx │ │ │ └── user-nav.ts │ │ ├── ui/ │ │ │ ... │ │ ├── auth-provider.tsx │ │ ├── icons.tsx │ │ └── theme-provider.tsx │ ├── lib/ │ │ ├── prisma.ts │ │ ├── types.d.ts │ │ └── utils.ts │ ... ...
Usaremos Prisma para almacenar y recuperar usuarios en la base de datos. Prisma permite la integración de diferentes tipos de bases de datos para que puedas usar cualquier base de datos que necesites, nosotros usaremos SQLite.
npx prisma init --datasource-provider sqlite
Esto crea la carpeta prisma con sus esquemas.
Para crear los modelos, usaremos los proporcionados por @auth/prisma-adapter y los personalizaremos un poco de la siguiente manera:
prisma/schema.prisma
:
generator client { provider = "prisma-client-js" output = "../../node_modules/.prisma/client" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } ... model User { id String @id @default(cuid()) username String password String email String @unique emailVerified DateTime? phone String? image String? } ...
3. Creando el primero
npx prisma migrate dev --name first-migration
Con este comando se han creado más archivos en la carpeta Prisma y se ha sincronizado la base de datos con los modelos.
Finalmente, creamos un Cliente Prisma.
lib/prisma.ts
:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = global as unknown as { prisma: PrismaClient; }; export const prisma = globalForPrisma.prisma || new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; export default prisma;
env
.
# Secret key for NextAuth.js, used for encryption and session security. It should be a long, # random string unique to your application. NEXTAUTH_SECRET=XXX3B2CC28F123456C6934531CXXXXX # Base URL for your Next.js app, used by NextAuth.js for redirects and callbacks. NEXTAUTH_URL=http://localhost:3000/
Esta ruta permite manejar todas las solicitudes de autenticación (como inicio de sesión, cierre de sesión y devoluciones de llamadas de proveedores) en un único punto final.
src/app/api/auth/[...nextauth]
... // Imports the Prisma User type for typing. import { User } from '@prisma/client' // Configuration of authentication options for NextAuth. export const authOptions: AuthOptions = { ... // Defines authentication providers, in this case, only credentials. providers: [ CredentialsProvider({ name: 'Credentials', // Defines the required fields for authentication. credentials: { email: { label: 'Email', type: 'text' }, password: { label: 'Password', type: 'password' }, }, // Function to authenticate the user with the provided credentials. async authorize(credentials) { // Searches for the user in the database by email. const user = await prisma.user.findUnique({ where: { email: credentials?.email, }, }) // Checks if the user exists and if the password is correct. if (!user) throw new Error('User name or password is not correct') if (!credentials?.password) throw new Error('Please Provide Your Password') const isPasswordCorrect = await bcrypt.compare(credentials.password, user.password) if (!isPasswordCorrect) throw new Error('User name or password is not correct') // Returns the user without including the password. const { password, ...userWithoutPass } = user return userWithoutPass }, }), ], } // Exports the configured NextAuth handler to handle GET and POST requests. const handler = NextAuth(authOptions) export { handler as GET, handler as POST }
4. Creación del proveedor de autenticación.
src/components/auth-provider.tsx
:
'use client' import { SessionProvider } from 'next-auth/react' export default function AuthProvider({ children }: { children: React.ReactNode }) { return <SessionProvider>{children}</SessionProvider> }
Este componente actúa como proveedor de sesión para aplicaciones Next.js que utilizan NextAuth para la autenticación.
Envolver componentes o páginas en este proveedor les otorga acceso al contexto de la sesión, lo que permite que los componentes secundarios utilicen funciones y enlaces de NextAuth, como useSession
, para acceder o modificar el estado de la sesión actual del usuario.
src/app/layout.tsx
:
/* All imports */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang='en' suppressHydrationWarning > <body className={`${inter.className} relative`}> <AuthProvider> <main>{children}</main> </AuthProvider> </body> </html> ) }
Would you like to use TypeScript (recommended)? yes Which style would you like to use? › Default Which color would you like to use as base color? › Slate Where is your global CSS file? › src/app/globals.css Do you want to use CSS variables for colors? › yes Are you using a custom tailwind prefix eg. tw-? Leave blank Where is your tailwind.config.js located? › tailwind.config.js Configure the import alias for components: › @/components Configure the import alias for utils: › @/lib/utils Are you using React Server Components? › yes
Puedes utilizar el tema de tu elección.
2. Implementación
src/app/layout.tsx
:
/* All imports */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang='en' suppressHydrationWarning > <body className={`${inter.className} relative`}> <AuthProvider> <ThemeProvider attribute='class' defaultTheme='dark' enableSystem disableTransitionOnChange > <main>{children}</main> <Toaster /> </ThemeProvider> </AuthProvider> </body> </html> ) }
2. Instalar los siguientes componentes shadcn/ui:
pnpm dlx shadcn-ui@latest add avatar button dropdown-menu form input label tabs toast
src/components/auth-buttons.tsx
:
'use client' import Link from 'next/link' import { signIn, useSession } from 'next-auth/react' import { Button } from '../ui/button' import { UserNav } from './user-nav' export default function AuthButtons() { // Use the useSession hook to access session data const { data: session } = useSession() return ( <div className='flex justify-end gap-4'> {session && session.user ? ( <UserNav user={session.user} /> ) : ( <> <Button size={'sm'} variant={'secondary'} onClick={() => signIn()} > Sign In </Button> <Button size={'sm'} asChild className='text-foreground' > <Link href='/auth/signup'>Sign Up</Link> </Button> </> )} </div> ) }
Este componente muestra dinámicamente opciones de autenticación según el estado de la sesión del usuario. Si el usuario ha iniciado sesión, muestra una navegación específica del usuario. De lo contrario, ofrece botones para iniciar sesión o registrarse, haciendo uso del enrutamiento de Next.js y las capacidades de autenticación de NextAuth para una experiencia de usuario fluida.
Modifique la página de inicio y agregue los botones de autenticación. Así es como se ve:
src/components/auth/signup-form.tsx
:
'use client' /* all imports */ // Function to register a new user import { registerUser } from '@/actions/auth-actions' // Define the validation schema for the signup form using Zod const formSchema = z .object({ username: z .string({ required_error: 'Username is required', }) .min(2, 'User name must have at least 2 characters') .max(12, 'Username must be up to 12 characters') .regex(new RegExp('^[a-zA-Z0-9]+$'), 'No special characters allowed!'), email: z.string({ required_error: 'Email is required' }).email('Please enter a valid email address'), password: z .string({ required_error: 'Password is required' }) .min(6, 'Password must have at least 6 characters') .max(20, 'Password must be up to 20 characters'), confirmPassword: z .string({ required_error: 'Confirm your password is required' }) .min(6, 'Password must have at least 6 characters') .max(20, 'Password must be up to 20 characters'), }) .refine(values => values.password === values.confirmPassword, { message: "Password and Confirm Password doesn't match!", path: ['confirmPassword'], }) // Type inference for form inputs based on the Zod schema type InputType = z.infer<typeof formSchema> export function SignUpForm() { const [isLoading, setIsLoading] = useState(false) const { toast } = useToast() // Hook to show toast notifications // Initialize form handling with React Hook Form and Zod for validation const form = useForm<InputType>({ resolver: zodResolver(formSchema), }) // Handles form submission async function onSubmit(values: InputType) { try { setIsLoading(true) const { confirmPassword, ...user } = values // Exclude confirmPassword from data to be sent const response = await registerUser(user) // Register the user if ('error' in response) { toast({ title: 'Something went wrong!', description: response.error, variant: 'success', }) } else { toast({ title: 'Account Created!', description: 'Your account has been created successfully! You can now login.', }) } } catch (error) { console.error(error) toast({ title: 'Something went wrong!', description: "We couldn't create your account. Please try again later!", variant: 'destructive', }) } finally { setIsLoading(false) } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <div className='grid gap-2'> // Each FormField validates and displays an input <FormField control={form.control} name='username' render={({ field }) => ( <FormItem> <FormControl> <div className='flex items-center gap-2'> <Icons.user className={`${form.formState.errors.username ? 'text-destructive' : 'text-muted-foreground'} `} /> <Input placeholder='Your Username' className={`${form.formState.errors.username && 'border-destructive bg-destructive/30'}`} {...field} /> </div> </FormControl> <FormMessage /> </FormItem> )} /> // Repeated structure for email, password, and confirmPassword with respective validations and icons <Button className='text-foreground mt-4' disabled={isLoading} // Disable button during form submission > {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading icon if isLoading is true Sign Up </Button> </div> </form> </Form> ) }
Este componente encapsula un formulario de registro de usuario, utilizando reaccionar-hook-form para la gestión del estado del formulario y Zod para la validación del esquema.
Si bien este no es un tutorial sobre estas tecnologías, solo utilizamos lo básico de ellas; si tiene más preguntas, puede consultar su documentación.
Agregué algunos estilos más a la página y se ve así:
src/actions/auth-action/ts
:
'use server' /* all imports */ export async function registerUser(user: Omit<User, 'id' | 'phone' | 'emailVerified' | 'image'>) { try { // Attempt to create a new user record in the database const result = await prisma.user.create({ data: { ...user, // Hash the password before storing it password: await bcrypt.hash(user.password, 10), }, }) return result } catch (error) { console.log(error) // Handle known request errors from Prisma if (error instanceof Prisma.PrismaClientKnownRequestError) { // Check for unique constraint failure (eg, email already exists) if (error.code === 'P2002') { return { error: 'Email already exists.' } } } // Return a generic error message for any other errors return { error: 'An unexpected error occurred.' } } }
La función registerUser
está diseñada para registrar de forma segura a un nuevo usuario mediante la creación de un registro en la base de datos con la información del usuario proporcionada, excluyendo campos como id
, phone
, emailVerified
e image
.
Utiliza bcrypt para codificar la contraseña del usuario para un almacenamiento seguro.
Para probar nuestro registro y validar que el usuario se está registrando correctamente, necesitamos agregar algunas devoluciones de llamada; estas son funciones que le permiten personalizar el comportamiento de autenticación y gestión de sesiones.
src/app/api/auth/[...nextauth]
:
... export const authOptions: AuthOptions = { // Define custom pages for authentication flow pages: { signIn: '/auth/signin', // Custom sign-in page }, // Configure session management to use JSON Web Tokens (JWT) session: { strategy: 'jwt', }, // JWT configuration, including secret for token signing jwt: { secret: process.env.NEXTAUTH_SECRET, // Secret used to sign the JWT, stored in environment variables }, ... // Callbacks for customizing JWT and session behaviors callbacks: { // Callback to modify the JWT content. Adds user information if available. async jwt({ token, user }) { if (user) token.user = user as User // Cast user object to User type and assign to token return token }, // Callback to modify session content. Adds user information to the session. async session({ token, session }) { session.user = token.user // Assign user information from token to session return session }, }, } ...
Callback jwt
: esta devolución de llamada se ejecuta cada vez que se crea o actualiza un JSON Web Token (JWT) durante el ciclo de vida de la autenticación. Le permite modificar el contenido del token antes de firmarlo y enviarlo al cliente o almacenarlo en el servidor.
Esto es útil para agregar información adicional al token, que puede ser relevante para la lógica de su aplicación.
session
de devolución de llamada: esta devolución de llamada se llama cada vez que se leen los datos de la sesión, como durante la representación del lado del servidor o en solicitudes de API protegidas. Permite modificar los datos de la sesión antes de enviarlos al cliente.
Esto es especialmente útil para agregar o modificar datos de sesión en función de la información almacenada en el JWT u otros criterios.
Finalmente, necesitamos ampliar las definiciones de tipo de Session
NextAuth y JWT
para incluir información adicional del usuario.
src/lib/types.d.ts
:
import { User } from '@prisma/client' declare module 'next-auth' { interface Session { user: User } } declare module 'next-auth/jwt' { interface JWT { user: User } }
Ahora, si completamos el formulario y lo enviamos, podremos ver el brindis de éxito. Para verificar que el usuario fue guardado en la base de datos, podemos ver gráficamente las tablas creadas por Prisma con el siguiente comando:
nxp prisma studio
Tendremos disponible la siguiente ruta http://localhost:5555
src/components/auth/user-nav.tsx
:
/* all imports */ interface Props { user: User // Expect a user object of type User from Prisma client } export function UserNav({ user }: Props) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant='ghost' className='relative h-8 w-8 rounded-full' > <Avatar className='h-9 w-9'> <AvatarImage src='/img/avatars/01.png' alt='' /> <AvatarFallback>UU</AvatarFallback> </Avatar> </Button> </DropdownMenuTrigger> <DropdownMenuContent className='w-56' align='end' forceMount > <DropdownMenuLabel className='font-normal'> <div className='flex flex-col space-y-1'> <p className='text-sm font-medium leading-none'>{user.username}</p> <p className='text-xs leading-none text-muted-foreground'>{user.email}</p> </div> </DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem> <Link href={'/api/auth/signout'} // Link to the signout API route className='w-full' > Sign Out </Link> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) }
src/components/auth/signin-form.tsx
:
/* all imports */ // Schema definition for form validation using Zod const formSchema = z.object({ email: z.string({ required_error: 'Please enter your email' }).email('Please enter a valid email address'), password: z.string({ required_error: 'Please enter your password', }), }) // Type inference for form inputs based on the Zod schema type InputType = z.infer<typeof formSchema> // Props definition, optionally including a callback URL interface Props { callbackUrl?: string } export function SignInForm({ callbackUrl }: Props) { const [isLoading, setIsLoading] = useState(false) const { toast } = useToast() const router = useRouter() // Hook to control routing const form = useForm<InputType>({ resolver: zodResolver(formSchema), // Set up Zod as the form validation resolver }) // Function to handle form submission async function onSubmit(values: InputType) { try { setIsLoading(true) // Attempt to sign in using the 'credentials' provider const response = await signIn('credentials', { redirect: false, // Prevent automatic redirection email: values.email, password: values.password, }) // Handle unsuccessful sign in attempts if (!response?.ok) { toast({ title: 'Something went wrong!', description: response?.error, variant: 'destructive', }) return } toast({ title: 'Welcome back! ', description: 'Redirecting you to your dashboard!', }) router.push(callbackUrl ? callbackUrl : '/') // Redirect to the callback URL or home page } catch (error) { toast({ title: 'Something went wrong!', description: "We couldn't create your account. Please try again later!", variant: 'destructive', }) } finally { setIsLoading(false) } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <div className='grid gap-2'> <div className='grid gap-1'> <FormField control={form.control} name='email' render={({ field }) => ( <FormItem> <FormControl> <div className='flex items-center gap-2'> <Icons.email className={`${form.formState.errors.email ? 'text-destructive' : 'text-muted-foreground'} `}/> <Input type='email' placeholder='Your Email' className={`${form.formState.errors.email && 'border-destructive bg-destructive/30'}`} {...field} /> </div> </FormControl> <FormMessage /> </FormItem> )} /> {/* Password field */} {/* Similar structure to email field, customized for password input */} </div> <Button className='text-foreground mt-4' disabled={isLoading} // Disable button while loading > {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading spinner when processing Sign In </Button> </div> </form> </Form> ) }
🎉 ¡Ya está!
Hemos terminado de implementar una autenticación básica con NextAuth.js. Aún quedan muchas cosas por hacer para tener un sistema de autenticación completo y las cubriremos en los próximos tutoriales.
En resumen, hemos explorado cómo implementar y personalizar un sistema de autenticación en Next.js usando NextAuth, cómo extender sesiones y JWT para enriquecer la administración de usuarios y cómo manejar formularios con validación efectiva usando reaccionar-hook-form y Zod.
¿Quieres conectarte con el autor?
Me encanta conectarme con amigos de todo el mundo en
También publicado aquí