Este es un tutorial que le mostrará cómo puede utilizar Auth.js para la autenticación de su aplicación Next.js. En este tutorial sólo utilizaré componentes de servidor. También voy a utilizar el método de "credenciales" para la autenticación.
Antes de empezar, mencionaré que utilizo los siguientes paquetes
npm i next-auth
para autenticación
npm i bcrypt
para la generación de hash y verificación de contraseñas
npm i zod
para facilitar la validación de datos
npm i mongodb
para acceso a base de datos (MongoDB)
Middleware
Digamos que tenemos un proyecto con una página /admin y queremos que los usuarios inicien sesión introduciendo un nombre de usuario y una contraseña.
Queremos redirigir a los usuarios no autorizados a una página /login. Podemos hacer esto usando el middleware Next.js.
// /middleware.ts
import { User } from "next-auth";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export const middleware = async( request: NextRequest ) => {
const session:session = await fetch(`${process.env.serverURL}/api/auth/session`, {
headers:headers(),
// cache: "no-store"
})
.then( async( res ) => await res.json() );
const loggedIn = Object.keys(session).length > 0? true :false;
const pathname = request.nextUrl.pathname;
if ( pathname != "/admin/login" && !loggedIn ){
return NextResponse.redirect( new URL( '/admin/login', process.env.serverURL ) );
}
}
export const config = {
matcher : ["/admin/:path*"]
}
type session = {} | User;
Nota: middleware.ts
debe estar fuera de la carpeta /app.
EDIT: Al enviar formularios el middleware anterior puede dar errores.
Internal error: Error: Unexpected end of form
or
fetch failed
Hay 2 maneras de solucionar este problema:
- Utilizar sólo las cabeceras necesarias
{ //inside middleware()
...
const headersList = headers();
const authHeader = headersList.get('authorization');
const cookieHeader = headersList.get('cookie');
const session = await fetch(url, {
headers: {
// "authorization": authHeader, //might not be needed
"cookie": cookieHeader
},
...
}
2. Llamar al middleware sólo en peticiones 'GET' ( no recomendado ).
{
...
if ( request.method === "GET" ){
// your code
}
}
Internal error: Error: Unexpected end of form
or
fetch failed
La página de inicio de sesión
Para que Auth.js
funcione necesitamos proporcionar un csrfToken
junto con nuestro formulario.
Su página de inicio de sesión debe ser algo como esto:
// /app/admin/login/page.tsx
import { headers } from "next/headers";
const LoginPage = async( { searchParams }:{ searchParams: { error?: string } } ) => {
const csrfToken = await fetch(`${process.env.serverURL}/api/auth/csrf`,{
headers: headers(),
})
.then( res => res.json() )
.then( csrfTokenObject => csrfTokenObject?.csrfToken );
return (
//using TailwindCSS classes btw.
<main className="flex flex-col items-center mt-2">
<form
method="POST"
action={`${process.env.serverURL}/api/auth/callback/credentials`}
className="flex flex-col group gap-2">
<input
className="outline-none focus:border-b border-black"
required
placeholder="login"
name="login"/>
<input
className="outline-none focus:border-b border-black"
required
placeholder="password"
name="password"
type="password"/>
<input
hidden
value={csrfToken}
name="csrfToken"
readOnly/>
<button
className="outline-none
focus:underline focus:decoration-red-600
focus:group-valid:decoration-green-600">
submit
</button>
</form>
{
searchParams.error &&
<p
className="text-red-600 text-center capitalize">
login failed.
</p>
}
</main>
)
}
export default LoginPage;
Ten en cuenta que el formulario personalizado no funcionará sin el csrfToken
.
Si estuvieras usando el cliente, podrías obtener el token vía await getCsrfToken()
dentro de un async()
dentro de useEffect()
.
Sin embargo, quiero mantener las cosas simples, así que lo hice de esta manera.
Verificación de usuarios
Voy a utilizar MongoDB para almacenar las credenciales de administrador. Un documento admin tendrá este aspecto:
{
_id: ObjectId,
login: string,
password: string //hased password (check bcrypt documentation)
}
Para hacer hash y verificar las contraseñas usaremos bcrypt
Nuestra ruta lógica de verificación puede ser algo así:
//e.g. /app/api/verification/route.ts
import { MongoClient, ObjectId } from "mongodb";
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { z } from "zod";
export const POST = async ( request: NextRequest ) => {
const client = new MongoClient(process.env.MONGODB_URI!);
try{
const credentials:credentials = await request.json();
const login = z.string()
.regex( /^[A-Z0-9]+$/gmi, "Invalid username" ) //I only accept alphanumeric chars.
.parse( credentials.login );
const admins = client.db('myDatabase').collection('admins');
const mongoResult : adminResult | null = await admins
.findOne( { login: login } ) as unknown as adminResult | null;
if (mongoResult){
//Verify password
const passwordIsCorrect = await bcrypt
.compare( credentials.password, mongoResult.password );
if ( passwordIsCorrect ){
const customUser: customUser = {
id: mongoResult._id.toString(), //required field!!
username: login,
}
return NextResponse.json( customUser );
}
}
return NextResponse.json( null );
}
catch(err) {
return NextResponse.json( null );
}
finally{
client.close();
}
}
type credentials = {
login:string,
password:string
}
type adminResult = {
_id : ObjectId,
login : string,
password : string
}
export type customUser = {
id : string,
username : string,
}
Auth.js
La estructura de archivos por defecto debería ser la siguiente
/app/api/auth/[...nextauth]/ruta.ts
Nota: Puede configurar este /api/auth cambiando su variable NEXTAUTH_URL .env.
e.g.
NEXTAUTH_URL=https://example.com/myapp/api/authentication
// /api/auth/signin -> /myapp/api/authentication/signin
// /app/api/auth/[...nextauth]/route.ts
import { customUser } from "@/app/api/verification"
import NextAuth, { User } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
const handler = NextAuth({
providers: [
CredentialsProvider({
//this field is not necessary
//unless you use the built-in form.
//However it also gives us our "credentials" type below.
credentials: {
login: { label: "username" },
password: { label: "password", type: "password" }
},
//the credentials are passed with our
//login form.
async authorize(credentials) {
//verify our credentials using the route
//we created above
const user: customUser|null = await fetch ( `${process.env.serverURL}/api/verification`,
{
method: "POST",
body: JSON.stringify(credentials),
cache:"no-cache" //! To be removed after done testing
})
.then( async( res ) => {
if (res.ok) return await res.json();
else return null;
})
if ( user && user.username) {
const sessionUser: User = {
id: user.id, // required string !!!
name: user.username, // undefined | null | string
email: undefined, // undefined | null | string
image: undefined // undefined | null | string
}
return sessionUser;
}
return null;
},
}),
],
//remove this field
//if you use the built-in form
pages: {
signIn: '/admin/login' //default is /api/auth/signin
//this will redirect us
//to our custom login page
//including an error searchParam
//if there is an issue.
}
})
export { handler as POST, handler as GET }
¡¡Algunas cosas a tener en cuenta!!
Errores en la página de inicio de sesión con Vercel
Por alguna razón este código dentro de la página de inicio de sesión:
const csrfToken = await fetch(`${process.env.serverURL}/api/auth/csrf`,{
headers: headers(),
})
.then( res => res.json() )
.then( csrfTokenObject => csrfTokenObject?.csrfToken );
Lanza un error al desplegar con Vercel.
//browser
Application error: a server-side exception has occurred
//server logs
a [Error]: Headers cannot be modified. Read more: https://nextjs.org/docs/app/api-reference/functions/headers
at Proxy.callable (/var/task/node_modules/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js:93:45157)
at ye (/var/task/___vc/__launcher/__launcher.js:14:6533)
at t.mutateHeaders (/var/task/___vc/__launcher/__launcher.js:14:7944)
at /var/task/___vc/__launcher/__launcher.js:7:991
at /var/task/___vc/__launcher/__launcher.js:14:5246
at _optionalChain (/var/task/___vc/__launcher/__launcher.js:7:969)
at i (/var/task/___vc/__launcher/__launcher.js:14:5168)
at i (/var/task/node_modules/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js:117:617)
at A (/var/task/node_modules/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js:135:66295)
at /var/task/node_modules/next/dist/compiled/nex
Este error no parece aparecer durante el desarrollo o cuando autoalojo la compilación.
Una solución podría ser obtener el csrfToken
mediante cookies()
.
// /app/admin/login/page.tsx
import { cookies } from "next/headers";
//async not needed here
const LoginPage = ( { searchParams }:{ searchParams: { error?: string } } ) => {
const csrfToken = cookies() //Might be empty before the first submit
.getAll()
.find( cookie => cookie.name == "next-auth.csrf-token")?.value
.split('|')[0];
//because it returns a string: "cookie1|cookie2"
...
Siempre puedes usar un componente cliente también.
EDIT: Puedes arreglar este error usando headers().get('cookie')
de la misma manera que se menciona en un 'EDIT' más arriba ( revisa la sección middleware ).
Una última cosa a tener en cuenta:
Inicialmente estaba usando la cookie de sesión para comprobar si el usuario había iniciado sesión. Sin embargo eso era una vulnerabilidad de seguridad.
Nunca hagas algo como esto:
// /middleware.ts
import { cookies } from "next/headers"
import { NextResponse } from "next/server";
export const middleware = ( ) => {
const authCookie = cookies().get('next-auth.session-token');
if ( !authCookie || !authCookie.value ){
return NextResponse.redirect(`${process.env.serverURL}/admin/login`);
}
//this approach is a security vulnerability
//as anyone can bypass this redirect by
//faking the cookie
//e.g. type in the browser console:
//document.cookie = "next-auth.session-token=[randomString]";
}
export const config = {
matcher : ["/admin/:path"]
}
Espero que este post haya ayudado a alguien.