4. Users & sessions
Tijdens deze les bespreken we hoe we user authenticatie kunnen toevoegen door middel van klassieke session tokens.
Startbestanden
Authenticatie
Authenticatie via sessions werkt op basis van een session token die op de server gegenereerd wordt, en vervolgens naar de client gestuurd wordt in de vorm van een cookie. De session token wordt dus niet enkel op de client bewaard, maar ook op de server. We spreken daarom van een stateful authenticatie systeem, in tegenstelling tot een stateless systeem zoals JSON Web Tokens (JWT) waarbij de server geen informatie bewaard over de sessie.
Omdat de server informatie bewaard over de sessie, kan (en moet) de server steeds valideren of de token die de client stuurt overeenkomt met de token op de server. Als de tokens overeenkomen, moet ook gecontroleerd worden of de sessie nog actief is. Als ook dit het geval is, is de gebruiker geauthenticeerd en kan deze de applicatie gebruiken.
Database schema
Het databaseschema, dat al beschikbaar is in de startbestanden, bevat de tabellen User en Session en een enum Role. In de User tabel zijn de email en password kolommen voorzien en worden er voor elke gebruiker één of meerdere sessies bewaard. We hebben duidelijk meerdere sessies per gebruiker nodig, anders is het niet mogelijk om in te loggen op verschillende toestellen. Verder wordt er ook rol van de gebruiker bewaard in de User tabel, aangezien dit in dit voorbeeld louter illustratief is, voorzien we enkel een user rol. Het enum is echter eenvoudig uit te breiden naar meerdere rollen.
Merk ook op dat we voor de relatie tussen de User en Session tabel een cascade relatie gebruiken. Dit betekent dat alle sessies voor een bepaalde gebruiker verwijderd worden als de gebruiker verwijderd worden.
enum Role {
User
}
model User {
id String @id @unique @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
password String
username String
role Role @default(User)
sessions Session[]
}
model Session {
id String @id @unique @default(uuid()) @db.Uuid
activeFrom DateTime @default(now())
activeUntil DateTime
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}Om te garanderen dat een gebruiker enkel toegang heeft tot hun data, voegen we ook een foreign keys toe in de Contact, Meeting en Tag tabellen die verwijzen naar de eigenaar.
Aangezien we toch in elke server function moeten controleren of de gebruiker ingelogd is, kunnen we het userId eenvoudig meegeven met elke query.
model Contact {
id String @id @default(uuid()) @db.Uuid
firstName String @db.VarChar(255)
lastName String? @db.VarChar(255)
description String? @db.VarChar(255)
avatar String @default("/avatars/placeholder.png") @db.VarChar(255)
/// [ContactInfo]
contactInfo Json @default("[{}]") @db.JsonB
favorite Boolean @default(false) @db.Boolean
meetings Meeting[]
tags Tag[]
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Meeting {
id String @id @default(uuid()) @db.Uuid
title String
description String?
date DateTime
// Komt overeen met CONTRAINT FK_Meeting_Contact FOREIGN KEY (contactId) REFERENCES Contact(id) ON DELETE CASCADE
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactId String @db.Uuid
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Tag {
id String @id @default(uuid()) @db.Uuid
name String
description String?
contacts Contact[]
userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @unique @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
password String
username String
role Role @default(User)
sessions Session[]
contacts Contact[]
meetings Meeting[]
tags Tag[]
}Na deze wijzigingen kunnen we een nieuwe migration aanmaken, aangezien de foreign keys in de User tabel verplicht zijn, zal Prisma een foutmelding geven als je deze migratie doorvoert op een bestaande database (die van vorige les). Via de --create-only optie maken we eerst de migratie aan zonder deze uit te voeren, vervolgens gebruiken we het reset commando om het seed script dat uitgebreid is en nu ook een gebruiker, meetings en tags aanmaakt, uit te voeren.
pnpm prisma migrate dev --create-only
pnpm prisma migrate resetnpx prisma migrate dev --create-only
npx prisma migrate resetbun prisma migrate dev --create-only
bun prisma migrate resetyarn prisma migrate dev --create-only
yarn prisma migrate resetNa deze migratie is er een gebruiker aangemaakt met onderstaande gegevens[1]:
- Email: test@example.com
- Wachtwoord: Test123Test
Account aanmaken
Een account aanmaken is relatief eenvoudig. Er is reeds een formulier, server action en Prisma methode voorzien. Als het formulier ingestuurd wordt, wordt er al een nieuwe account aangemaakt. Er is echter nog een groot probleem met deze structuur, de wachtwoorden worden momenteel bewaard als plain tekst.
Het is vanzelfsprekend dat een wachtwoord niet als plain text bewaard mag worden, dit maakt het veel te eenvoudig voor kwaadwilligen om een gebruiker hun wachtwoord te weten te komen. Hiervoor moet de database niet eens gehackt worden, elke developer die toegang heeft tot de production database zou deze wachtwoorden kunnen uitlezen, en de wachtwoorden en emails proberen te gebruiken om op andere websites in te loggen.
In de plaats van plain text moeten de wachtwoorden gehasht worden, hiervoor zijn heel veel algoritmes beschikbaar. Enkele voorbeelden zijn MD5, SHA-1, SHA-512, bcrypt, scrypt, PBKDF2, agron2i, argon2d, ... Veel van deze algoritmes zijn niet meer veilig omdat hardware zodanig geëvolueerd is dat het eenvoudig wordt om het wachtwoord te achterhalen als de hash gekend is. In deze cursus gebruiken we PBKDF2, een algoritme dat doorheen verschillende iteraties nieuwe hashes berekend. Omdat er meerdere iteraties zijn wordt het wachtwoord moeilijker te kraken en wordt er gecompenseerd voor zwakkere wachtwoorden.
Waarschuwing
Deze cursus is geen cryptografisch advies, voordat je zelf een hashing algoritme kiest, is het aan te raden om een cyber-security expert te raadplegen. Het is, in veel situaties, beter om een authentication services of libraries te gebruiken die professioneel geaudit zijn dan zelf iets te ontwerpen.
Daarnaast is het algoritme dat we gebruiken niet meer de beste keuze, argon2[2] is bijvoorbeeld een betere keuze omdat dit algoritme niet enkel de tijd maar ook de memory kost parametriseert.
Voordat we het wachtwoord hashen wordt er eerst een salt toegevoegd.
Begrip: Salt
Een salt is een willekeurige string die uniek is voor elk wachtwoord in de database. Door deze string te concateneren met het wachtwoord voordat dit gehasht wordt, wordt er voor twee gebruikers die hetzelfde wachtwoord gekozen hebben een andere hash bewaard in de database.
Naast deze obfuscatie heeft salt nog een ander bijzonder groot voordeel. Omdat elk wachtwoord gehasht is met een andere salt, wordt het onmogelijk om een rainbow table op te stellen. Dit is een tabel waar de hashes van veel voorkomende wachtwoorden teruggevonden kunnen worden, zo kunnen kwaadwilligen zeer snel inloggen op verschillende accounts als ze toegang gekregen hebben tot User tabel.
Omdat de salt uniek is per gebruiker, mag deze in de database bewaard worden. Zelfs als een hacker hier toegang tot krijgt, is dit niet erg, want er zijn zodanig veel mogelijke wachtwoorden dat het computationeel onmogelijk is om het wachtwoord te raden. Om het wachtwoord te bepalen zou de hacker alle mogelijke wachtwoorden moeten hashen met de salt van de gebruiker en dit voor elke gebruiker opnieuw doen.
Als je nog een stap verder wilt gaan is het ook mogelijk om een pepper toe te voegen, maar dat doen we in deze cursus niet.
Begrip: Pepper
Pepper is een willekeurig genereerde string die met het wachtwoord en salt gecombineerd wordt, maar die gelijk is voor elke gebruiker.
De pepper wordt niet bewaard in de database omdat deze, in tegenstelling tot salt, geheim is. Pepper wordt best bewaard in een hardware security module (HSM).
Hieronder gebruiken we het PBKDF2 algoritme om het wachtwoord te hashen en vervolgens te bewaren in de database. De hash die gegenereerd wordt door de pbkdf2Sync methode bevat geen salt, daarom voegen we deze (en de gebruikte parameters) toe in de string die in de database bewaard wordt. Dit is cruciaal, als we de salt niet zouden bewaren, wordt het onmogelijk om een wachtwoord te verifiëren. Elke salt is uniek en moet dus samen met het wachtwoord bewaard worden.
Merk op dat we voor de twee opties die we meegeven aan de hashfunctie willekeurig gekozen waarden gebruiken. In een productieomgeving moeten deze waarden afgestemd worden zodat het niet te lang duurt om de hash te berekenen, maar zodat het toch veilig is. Als het aantal iterations groter wordt, is de hash veiliger, maar duurt het langer om te berekenen.
Ook de parameters moeten bewaard worden in de database, alhoewel die momenteel voor elke gebruiker hetzelfde zijn, kunnen we de waarden in de toekomst eventueel verhogen om rekening te houden met snellere CPU's. Als de parameters mee bewaard worden kunnen we de oude hashes nog steeds verifiëren, ook als de parameters geüpdatet zijn.
In hashing worden regelmatig byte-arrays gebruikt in de plaats van een string interpretatie daarvan. Via de toString methode kunnen we zo'n array converteren naar een string. De 'hex' parameter geeft aan hoe de string geïnterpreteerd moet worden, maar heeft verder weinig invloed, we zouden hier evengoed kunnen kiezen om de string te interpreteren als een base64 string of ASCII tekst. De tekens die voor kunnen komen in het resultaat zijn het enige verschil. In een hexadecimale string zijn slechts 16 karakters beschikbaar[3]. Dit betekent dat het veilig is om een dollar teken te gebruiken om de hash, salt en parameters van elkaar te scheiden in de database, want het dollar teken zal nooit voorkomen in de hash.
Aangezien we gebruik maken van de pbkdf2Sync en randomBytes methodes moeten we eerst de Node types installeren, anders worden er TypeScript fouten gegenereerd.
pnpm add -D @types/nodenpm install -D @types/nodebun add -d @types/nodeyarn add -D @types/nodeimport 'server-only'
import {pbkdf2Sync, randomBytes} from 'crypto'
export const hashOptions = {
keyLength: 64,
iterations: 600000,
}
export function hashPassword(password: string): string {
const salt = getSalt()
const hash = pbkdf2Sync(password, salt, hashOptions.iterations, hashOptions.keyLength, 'sha512').toString('hex')
return `${hashOptions.iterations}$${hashOptions.keyLength}$${hash}$${salt}`
}
export function getSalt(): string {
return randomBytes(32).toString('hex')
}export async function createUser(data: CreateUserParams): Promise<Profile> {
return prismaClient.user.create({
data: {
...data,
password: hashPassword(data.password),
},
omit: profileOmit,
})
}Hieronder zijn twee voorbeeld hashes te zien (inclusief parameters en salt), deze zijn allebei gegenereerd op basis van het wachtwoord 'test123test' en tonen duidelijk dat eenzelfde wachtwoord een andere hash genereert.
600000$64$5ee216d27306e5093b168627b4f65f20ea21d44232ff7ea9869b2819c807ae94083783247a154ed29c8bcb6dfcfb414690cf7dbd16660090f46e115bae682fec$f0a3397fe6a343c7f11449e4b16a42522478f96e9b80d54d9978519c0956915d
600000$64$d26a68cf74c98df850ce91e6a617cac30cd401646dac26dd9a4b59d45bda0459e6565671c2d5fcb3b8e8af812f6641d320e66b5c582fea184115cebee3251584$5365ac70b0f55ab91348e6a98d24dd82fa08cf3cd84a4dcbc5f5f6a28d0fea6aAangezien de registerAction al geïmplementeerd is, moeten we verder niets doen om het registratieproces af te werken.
Inloggen
Aangezien de wachtwoorden als hashes bewaard zijn, kunnen we niet gewoon controleren of een wachtwoord voorkomt in de database. We moeten het gehashte wachtwoord en de bijhorende salt uitlezen, zodat we het wachtwoord dat door de gebruiker ingegeven is, kunnen hashen met dezelfde salt die gebruikt is tijdens het registratieproces.
export function verifyPassword(dbHash: string, password: string): boolean {
const [iterations, hashLength, hash, salt] = dbHash.split('$')
const passwordHash = pbkdf2Sync(password, salt, Number(iterations), Number(hashLength), 'sha512').toString('hex')
return passwordHash === hash
}Om in te loggen moeten we eerst de gebruiker ophalen op basis van het e-mailadres dat ingegeven is in de frontend. Vervolgens kunnen we de hash die in de database bewaard is vergelijken met de hash die we berekenen op basis van het ingegeven wachtwoord. Als de hashes overeenkomen, is het wachtwoord correct en mag de gebruiker inloggen.
Als de gebruiker niet gevonden wordt in de database, gebruiken we een fake wachtwoord om te voorkomen dat een aanvaller kan afleiden of een e-mailadres bestaat of niet op basis van de tijd die nodig is om een antwoord te krijgen van het login endpoint. Als we dit niet toevoegen kan een kwaadwillige gebruiker na één wachtwoord per e-mail te proberen, al achterhalen of het wachtwoord fout is of dat de gebruiker niet bestaat. Je helpt hier de aanvaller dus enkel mee.
Merk op dat we geen error mogen opgooien als het wachtwoord fout in, in plaats daarvan moeten we de functie gewoon vroegtijdig stoppen met een return statement. Als we een foutmelding zouden opgooien wordt de error boundary geactiveerd.
import {redirect} from 'next/navigation'
export async function signInAction(_prevData: unknown, formData: FormData): Promise<void> {
const params = convertFormData<{email: string; password: string}>(formData)
const user = await getUserByEmail(params.email)
const logger = await getLogger()
const timingSafePassword = `${hashOptions.iterations}$${hashOptions.keyLength}$preventTimingBasedAttacks123$${getSalt()}`
const isValidPassword = verifyPassword(user?.password ?? timingSafePassword, params.password)
if (!isValidPassword) {
logger.warn(`Failed authentication attempt for ${user?.id ?? params.email}`)
return
}
// De gebruiker is ingelogd, dus redirecten we naar de contactenpagina.
redirect('/contacts')
}Onderstaande video toont dat het inloggen werkt. Een geldige account wordt geaccepteerd, maar ongeldige inloggegevens worden afgewezen. De mislukte inlogpoging wordt in het laatste hoofdstuk meer gracieus afgehandeld.
De error op de contactpagina wordt hieronder verder uitgewerkt.
Sessies aanmaken & verwijderen
Om een sessie te starten moeten we een nieuwe rij toevoegen in de Session tabel. Hiervoor moet de activeUntil kolom ingevuld worden.
De sessie blijft best niet al te lang actief, maar natuurlijk mag deze ook niet te kort zijn. De meest veilige geldigheidstermijn is 0 seconden, maar dit is natuurlijk heel onaangenaam voor de gebruiker want dan zou de gebruiker de inloggegevens met elk request moet meesturen. Dit brengt dan weer meer risico met zich mee wat betreft het onderscheppen van het wachtwoord, natuurlijk is dit geëncrypteerd via TLS op HTTPS verbindingen en is de kans dat het wachtwoord onderschept wordt relatief klein. De meest gebruiksvriendelijke optie is om de sessie voor altijd actief te houden, maar ook dit is niet ideaal omdat de sessie dan niet vervalt als het toestel waarop ingelogd is gestolen wordt of ergens achtergelaten wordt.
Het is duidelijk dat een balans gevonden moet worden tussen gebruiksvriendelijkheid en veiligheid. In deze cursus houden we de sessie actief voor 24 uur, afhankelijk van het soort website kan dit verhoogt of verlaagt worden. Zo houdt Netflix een sessie actief voor 1 jaar en houden banken de sessie slechts actief totdat het tabblad gesloten wordt.
De geldigheidstermijn van een sessie wordt per rol geconfigureerd zodat we deze eenvoudig kunnen aanpassen als dit nodig moest zijn. Bijvoorbeeld als we een admin rol toevoegen die sneller opnieuw moet inloggen.
Om een sessie te stoppen kunnen we de activeUntil kolom updaten naar het huidige tijdstip, of de sessie verwijderen. In deze cursus kiezen we voor de eerste optie.
import {Role} from '@/generated/prisma/enums'
export const SessionDuration = {
[Role.User]: 1000 * 60 * 60 * 24, // 24 uur
} satisfies Record<Role, number>export async function startSession(userId: string, role: Role): Promise<Session> {
return prismaClient.session.create({
data: {
userId,
activeUntil: new Date(Date.now() + SessionDuration[role]),
},
})
}
export async function stopSession(id: string): Promise<void> {
await prismaClient.session.update({
where: {id},
data: {
activeUntil: new Date(),
},
})
}Vervolgens kunnen we deze functies integreren in signInAction, registerAction en signOutServerFunction.
export async function registerAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
const newUserInfo = convertFormData<CreateUserParams>(formData)
const user = await createUser(newUserInfo)
logger.info(`New user created: ${newUserInfo.id}`)
const session = await startSession(user.id, user.role)
logger.info(`New session started: ${session.id}, ends at ${session.activeUntil.toISOString()}`)
redirect('/contacts')
}
export async function signInAction(_prevData: unknown, formData: FormData): Promise<void> {
const params = convertFormData<{email: string; password: string}>(formData)
const user = await getUserByEmail(params.email)
const logger = await getLogger()
const timingSafePassword = `${hashOptions.iterations}$${hashOptions.keyLength}$preventTimingBasedAttacks123$${getSalt()}`
const isValidPassword = verifyPassword(user?.password ?? timingSafePassword, params.password)
if (!isValidPassword) {
logger.warn(`Failed sign in attempt for ${params.email}.`)
return
}
logger.info(`Successful authentication request for ${user!.id}`)
const session = await startSession(user!.id, user!.role)
logger.info(`New session started: ${session.id}, ends at ${session.activeUntil.toISOString()}`)
redirect('/contacts')
}
export async function signOutServerFunction(): Promise<void> {
const logger = await getLogger()
const sessionId = await getSessionId()
if (sessionId) {
await stopSession(sessionId)
logger.info(`Session stopped: ${sessionId}.`)
}
redirect('/login')
}De startSession methode geeft een Session object terug dat vervolgens gebruikt kan worden om een cookie in te stellen. Aangezien het cookie ingesteld moet worden nadat een gebruiker registreert of inlogt en als de sessie verlengt wordt, zonderen we deze functionaliteit af in een aparte functie.
We gebruiken de string sessionId als naam van het cookie en als waarde het id van de nieuw aangemaakte sessie. Om het cookie veilig te maken moeten we enkele configuratieopties toevoegen.
httpOnly: Deze optie zorgt ervoor dat het cookie niet uitgelezen kan worden vanuit JavaScript, het cookie kan enkel door de browser uitgelezen worden.secure: Deze optie bepaald of het cookie al dan niet verstuurd mag worden over een HTTP-verbindingen. Aangezien het cookie gevoelige gegevens bevat, moet dit in een productieomgeving op true staan, zo worden enkel HTTPS verbinding toegestaan. In development gebruiken we geen HTTPS en zetten we deze optie op false.sameSite: Deze optie bepaald wanneer de browser het cookie meestuurt met een HTTP request. Door dit op strict te zetten, wordt het cookie enkel meegestuurd met requests naar het domein dat het cookie ingesteld heeft.path: Geeft aan voor welke pagina's op de website het cookie beschikbaar is.expires: Geef aan hoe lang het cookie beschikbaar blijft, na dit tijdstip wordt het cookie automatisch verwijderd.
Naast een functie om het cookie in te stellen, voegen we ook een functie toe om het cookie te verwijderen en om het sessionId uit te lezen. Vervolgens gebruiken we deze functies om signInAction, registerAction en signOutServerFunction af te werken.
import 'server-only'
import {cookies} from 'next/headers'
import {Session} from '@/generated/prisma/client'
const cookieName = 'sessionId'
export async function setSessionCookie(session: Session): Promise<void> {
const awaitedCookies = await cookies()
awaitedCookies.set({
name: cookieName,
value: session.id,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
expires: session.activeUntil,
})
}
export async function clearSessionCookie(): Promise<void> {
const awaitedCookies = await cookies()
awaitedCookies.delete(cookieName)
}
export async function getSessionId(): Promise<string | undefined> {
return (await cookies()).get(cookieName)?.value
}export async function registerAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
const newUserInfo = convertFormData<CreateUserParams>(formData)
const user = await createUser(newUserInfo)
logger.info(`New user created: ${newUserInfo.id}`)
const session = await startSession(user.id, user.role)
logger.info(`New session started: ${session.id}, ends at ${session.activeUntil.toISOString()}`)
await setSessionCookie(session)
redirect('/contacts')
}
export async function signInAction(_prevData: unknown, formData: FormData): Promise<void> {
const params = convertFormData<{email: string; password: string}>(formData)
const user = await getUserByEmail(params.email)
const logger = await getLogger()
const timingSafePassword = `${hashOptions.iterations}$${hashOptions.keyLength}$preventTimingBasedAttacks123$${getSalt()}`
const isValidPassword = verifyPassword(user?.password ?? timingSafePassword, params.password)
if (!isValidPassword) {
logger.warn(`Failed sign in attempt for ${params.email}.`)
return
}
logger.info(`Successful authentication request for ${user!.id}`)
const session = await startSession(user!.id, user!.role)
logger.info(`New session started: ${session.id}, ends at ${session.activeUntil.toISOString()}`)
await setSessionCookie(session)
redirect('/contacts')
}
export async function signOutServerFunction(): Promise<void> {
const logger = await getLogger()
const sessionId = await getSessionId()
if (sessionId) {
await stopSession(sessionId)
logger.info(`Session stopped: ${sessionId}.`)
await clearSessionCookie()
}
redirect('/login')
}Sessie verlengen
Omdat de sessie vervalt na 24 uur moeten we de sessie verlengen als deze dreigt te vervallen. Aangezien de proxy voor elk request uitgevoerd wordt, kunnen we hier een controle uitvoeren die de sessie verlengt als deze nog minder dan 12 uur geldig is.
Hiervoor schrijven we drie functies. De eerste functie pas de geldigheidsdatum van de sessie aan in de database. De tweede functie verlengt de sessie en past update het cookie. De laatste functie is de proxy die controleert of de sessie al dan niet verlengt moet worden.
export async function extendSession(id: string, role: Role): Promise<Session> {
return prismaClient.session.update({
where: {id},
data: {
activeUntil: new Date(Date.now() + SessionDuration[role]),
},
})
}export async function extendSessionAndSetCookie(id: string, role: Role): Promise<void> {
const session = await extendSession(id, role)
await setSessionCookie(session)
}export async function sessionManagementProxy(_: NextRequest, response: NextResponse): Promise<NextResponse> {
const session = await getSessionFromCookie()
if (!session || session.activeUntil.getTime() < Date.now()) return response
const logger = await getLogger()
if (session.activeUntil.getTime() - Date.now() < SessionDuration[session.user.role] / 2) {
await extendSessionAndSetCookie(session.id, session.user.role)
logger.info(`Extended session ${session.id} by ${SessionDuration[session.user.role]} ms`)
}
return response
}export const proxy = withProxy(loggingProxy, redirectProxy, sessionManagementProxy, corsProxy)Om te testen of de sessionManagementProxy werkt, kan je de / 2 weghalen in bovenstaande controle.
Routes beveiligen
Er zijn verschillende routes die niet toegankelijk mogen zijn voor niet ingelogde gebruikers. Op elk van deze routes moeten we een controle toevoegen die nagaat of de gebruiker ingelogd is en of de sessie nog actief is, aangezien de proxy functies voor elk request uitgevoerd worden, kunnen we de controle hier uitvoeren.
We beginnen met enkele hulpfuncties te schrijven die de sessie en het gebruikersprofiel voor de actieve sessie ophalen.
export async function getSessionFromCookie(): Promise<SessionWithProfile | null> {
const sessionId = await getSessionId()
return sessionId ? await getSessionProfile(sessionId) : null
}
export async function getSessionProfileFromCookie(): Promise<Profile | null> {
const session = await getSessionFromCookie()
return session?.user ?? null
}Vervolgens schrijven we een redirectProxy die een set bevat van alle publieke routes, beschermde routes en de publieke routes die een ingelogde gebruiker niet mag bezoeken.
Aangezien een route als /contacts/<contactId> in de proxy geregistreerd wordt als /contacts/044c225a-c00f-4ede-bbb8-a21e86e6c00c, moeten we de dynamische segmenten (parameters) vervangen met een algemene string om het matchen snel te laten verlopen. Hiervoor gebruiken we een reguliere expressie waarmee alle dynamische segmenten vervangen worden met :param.
Vervolgens doorlopen we alle mogelijke situaties en redirecten we indien nodig. Merk op dat we hiervoor een nieuwe instantie van NextResponse moeten aanmaken, hierdoor worden de proxies onmiddellijk gestopt en wordt de gebruiker geredirect.
const uuidV4Regex = new RegExp(/[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/gi)
const publicRoutes = new Set<string>(['/', '/login', '/register'])
const protectedRoutes = new Set<string>([
'/contacts',
'/contacts/new',
'/contacts/:param',
'/contacts/:param/edit',
'/meetings',
'/meetings/:param',
'/tags',
'/tags/:param',
'/account',
])
const publicRedirects: Record<string, string> = {
'/login': '/contacts',
'/register': '/contacts',
}
export async function redirectProxy(request: NextRequest, response: NextResponse): Promise<NextResponse> {
const parameterizedRoute = request.nextUrl.pathname.replaceAll(uuidV4Regex, ':param')
const session = await getSessionFromCookie()
const logger = await getLogger()
if (publicRedirects[parameterizedRoute] && session) {
return NextResponse.redirect(new URL(publicRedirects[parameterizedRoute], request.url))
}
if (publicRoutes.has(parameterizedRoute)) {
return response
}
if (protectedRoutes.has(parameterizedRoute) && session) {
return response
}
if (protectedRoutes.has(parameterizedRoute) && !session) {
logger.warn(`Someone tried to access ${request.nextUrl.pathname} while unauthenticated.`)
return NextResponse.redirect(new URL('/login', request.url))
}
logger.warn(`Granting access to ${request.nextUrl.pathname} because its access level hasn't been configured.`)
return response
}export const proxy = withProxy(
loggingProxy,
redirectProxy,
corsProxy)Pagina's beveiligen
Alle DAL-functies in de startbestanden zijn reeds aangepast zodat ze het id van de gebruiker als parameter ontvangen. Merk op dat we hiervoor heel wat aanpassingen hebben moeten doen omdat de Meeting, Tag en Contact tabellen nu een foreign key bevatten naar de User tabel.
Aangezien we hierboven gegarandeerd hebben dat gebruikers niet op de beveiligde pagina's kunnen geraken, kunnen we er van uit gaan dat het profiel beschikbaar is als deze pagina uitgevoerd wordt.
Daarom schrijven we functie die het profiel ophaalt en een foutmelding opgooit als deze niet gedefinieerd is, deze foutmelding zou nooit opgegooid mogen worden tenzij er een belangrijke fout gemaakt wordt in de authenticatie.
export async function getSessionProfileFromCookieOrThrow(): Promise<Profile> {
const session = await getSessionFromCookie()
if (!session) {
throw new Error("Couldn't retrieve the user's profile in getSessionProfileFromCookieOrThrow.")
}
return session?.user ?? null
}const ContactsPage: FunctionComponent<ContactsPageProps> = async ({searchParams}) => {
const {contactName} = await searchParams
const profile = await getSessionProfileFromCookieOrThrow()
const contacts = await getContacts(profile.id, contactName)
return (
<>
...
</>
)
}Voor de overige pagina's is dit al gebeurd in de startbestanden.
Server functions beveiligen
Elke server function moet controleren of de gebruiker ingelogd is, enkel de pagina's beveiligen is niet voldoende. Enerzijds zou de gebruiker de data kunnen aanpassen voordat het formulier ingezonden wordt, zodat een foutief gebruikersId ingezonden worden. Anderzijds is het mogelijk dat de applicatie lange tijd open gestaan heeft en dat de sessie vervallen is. Tenslotte is het ook geen goed idee om enkel op proxies te vertrouwen, zoals een recente vulnerability duidelijk gemaakt heeft.
De dal functies moeten naast de parameters die vorige les al aanwezig waren dus ook het id van de gebruiker ontvangen[4]. Het id wordt opgehaald vanuit de sessie token en wordt niet meegegeven als parameter aan de dal functie, op deze manier is het niet mogelijk om acties op te roepen voor data die eigendom is van een andere gebruiker.
export async function createContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('createContactAction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling createContactAction`)
return
}
const contact = convertFormData<CreateContactParams>(formData)
await createContact({...contact, userId: profile.id})
logger.info('createContactAction completed successfully')
revalidatePath('/contacts')
}
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
const logger = await getLogger()
logger.info('deleteContactServerFunction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling deleteContactServerFunction`)
return
}
// In de volgende lessen wordt validatie en autorisatie toe gevoegd.
await deleteContact(id, profile.id)
logger.info('deleteContactServerFunction completed successfully')
redirect('/contacts')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('updateContactAction called')
const contact = convertFormData<UpdateContactParams>(formData)
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling updateContactAction`)
return
}
await updateContact({...contact, userId: profile.id})
logger.info('updateContactAction completed successfully')
redirect(`/contacts/${contact.id}`)
}
export async function toggleFavoriteServerFunction({id}: {id: string}): Promise<void> {
const logger = await getLogger()
logger.info('toggleFavoriteServerFunction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling toggleFavoriteServerFunction`)
return
}
await toggleFavorite(id, profile.id)
revalidatePath(`/contacts`)
revalidatePath(`/contacts/${id}`)
logger.info('toggleFavoriteServerFunction completed successfully')
}export async function createMeetingAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('createMeetingAction called')
const meeting = convertFormData<CreateMeetingParams>(formData)
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling createMeetingActions`)
return
}
await createMeeting({...meeting, userId: profile.id})
revalidatePath('/meetings')
logger.info('createMeetingAction completed successfully')
}
export async function updateMeetingAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('updateMeetingAction called')
const meeting = convertFormData<UpdateMeetingParams>(formData)
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling updateMeetingAction`)
return
}
await updateMeeting({...meeting, userId: profile.id})
logger.info('updateMeetingAction completed successfully')
redirect(`/meetings`)
}
export async function cancelMeetingServerFunction({id}: {id: string}): Promise<void> {
const logger = await getLogger()
logger.info('cancelMeetingServerFunction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling cancelMeetingServerFunction`)
return
}
await deleteMeeting(id, profile.id)
logger.info('cancelMeetingServerFunction completed successfully')
redirect('/meetings')
}export async function createTagAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('createTagAction called')
const newTag = convertFormData<CreateTagParams>(formData)
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling createTagAction`)
return
}
await createTag({...newTag, userId: profile.id})
revalidatePath('/tags')
logger.info('createTagAction completed successfully')
}
export async function deleteTagServerFunction({id}: {id: string}): Promise<void> {
const logger = await getLogger()
logger.info('deleteTagServerFunction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling deleteTagServerFunction`)
return
}
await deleteTag(id, profile.id)
revalidatePath('/tags')
logger.info('deleteTagServerFunction completed successfully')
}
export async function linkTagsToContactsServerFunction({tagId, contactIds}: {tagId: string, contactIds: string[]}): Promise<void> {
const logger = await getLogger()
logger.info('linkTagsToContactsServerFunction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling linkTagsToContactsServerFunction`)
return
}
await linkTagsToContact(tagId, contactIds, profile.id)
revalidatePath(`/tags/${tagId}`)
logger.info('linkTagsToContactsServerFunction completed successfully')
}
export async function unlinkTagsFromContactServerFunction({tagId, contactIds}: {tagId: string, contactIds: string[]}): Promise<void> {
const logger = await getLogger()
logger.info('unlinkTagsToContactsAction called')
const profile = await getSessionProfileFromCookie()
if (!profile) {
logger.warn(`Unauthenticated user tried calling unlinkTagsToContactsAction`)
return
}
await disconnectTagsFromContact(tagId, contactIds, profile.id)
revalidatePath(`/tags/${tagId}`)
logger.info('unlinkTagsToContactsAction completed successfully')
}Info
Bovenstaande code is heel repetitief, in de laatste les wordt besproken hoe de repetitieve delen afgezonderd kunnen worden in een wrapper functie.
Autorisatie
Hierboven hebben we authenticatie besproken, i.e. het identificeren van gebruikers. Autorisatie is het proces waarbij bepaald wordt welke acties een gebruiker mag uitvoeren.
Om autorisatie toe te voegen, moet de redirectProxy uitgebreid worden met een set routes voor elke rol, vervolgens moet gecontroleerd worden of de gebruiker de juiste rol heeft en indien nodig wordt de gebruiker daarna geredirect.
Voor de server functions moet naast een controle op een actieve sessie ook een controle uitgevoerd worden op de rol van de gebruiker.
Caching
Zoals onderstaande video aantoont, wordt de getSessionProfile functie meerdere keren uitgevoerd als de website geladen wordt. Dit is natuurlijk niet ideaal want de database wordt dus ook twee keer aangesproken en dit is een relatieve dure operatie.
De 5 keer dat de functie opgeroepen wordt, kunnen we reduceren tot 2 keer.
De twee logs waarbij het pad, requestId en method ingevuld zijn komen uit de Next server, hier kunnen we de cache functie gebruiken om, binnen één request, het resultaat van de getSessionProfile functie te cachen.
import {cache} from 'react'
export const getSessionProfile = cache((id: string): Promise<SessionWithProfile | null> => {
return prismaClient.session.findUnique({
where: {
id,
activeUntil: {
gt: new Date(),
},
},
include: sessionWithProfileInclude,
})
})De 3 logs zonder pad, requestId of methode worden gegenereerd in de proxy functies. Hier werkt de cache functie niet, als alternatief verhuizen we de code die de session ophaalt naar de withProxy functie. Vervolgens geven we deze parameter door als derde parameter aan de ChainedProxy.
export type ChainedProxy = (
request: NextRequest,
response: NextResponse,
session: SessionWithProfile | null,
) => Promise<NextResponse> | NextResponse
export async function chainProxy(request: NextRequest, ...functions: ChainedProxy[]): Promise<NextResponse> {
let response = NextResponse.next()
// Skip internal Next files and any requests for static assets (paths that end with an extension).
if (request.nextUrl.pathname.startsWith('/_next') || request.nextUrl.pathname.match(/.*\.[^.]+$/)) return response
const session = await getSessionFromCookie()
for (const fn of functions) {
response = await fn(request, response, session)
}
return response
}export async function redirectProxy(
request: NextRequest,
response: NextResponse,
session: SessionWithProfile | null,
): Promise<NextResponse> {
const parameterizedRoute = request.nextUrl.pathname.replaceAll(uuidV4Regex, ':param')
const logger = await getLogger()
if (publicRedirects[parameterizedRoute] && session) {
return NextResponse.redirect(new URL(publicRedirects[parameterizedRoute], request.url))
}
if (publicRoutes.has(parameterizedRoute)) {
return response
}
if (protectedRoutes.has(parameterizedRoute) && session) {
return response
}
if (protectedRoutes.has(parameterizedRoute) && !session) {
logger.warn(`Someone tried to access ${request.nextUrl.pathname} while unauthenticated.`)
return NextResponse.redirect(new URL('/login', request.url))
}
logger.warn(`Granting access to ${request.nextUrl.pathname} because its access level hasn't been configured.`)
return response
}export async function sessionManagementProxy(
_: NextRequest,
response: NextResponse,
session: SessionWithProfile | null,
): Promise<NextResponse> {
if (!session || session.activeUntil.getTime() < Date.now()) return response
const logger = await getLogger()
if (session.activeUntil.getTime() - Date.now() < SessionDuration[session.user.role] / 2) {
await extendSessionAndSetCookie(session.id, session.user.role)
logger.info(`Extended session ${session.id} by ${SessionDuration[session.user.role]} ms`)
}
return response
}Na deze aanpassing wordt de getSessionProfile functie nog maar twee keer opgeroepen. Omdat de proxy en de Next servers in twee verschillende processen uitgevoerd worden, is het niet mogelijk om data te delen tussen proxy en Next. Aan de hand van een JWT (zie volgend hoofdstuk), kan een database call vermeden worden ten kostte van een klein beetje security.
Logging
Het is vanzelfsprekend dat logs toegevoegd moeten worden voor elke authenticatie actie. Verder is handig als het sessionId en userId toegevoegd worden aan elk log statement.
export async function loggingProxy(
request: NextRequest,
response: NextResponse,
session: SessionWithProfile | null,
): Promise<NextResponse> {
const awaitedCookies = await cookies()
const requestId = crypto.randomUUID()
response.headers.set('x-request-id', requestId)
response.headers.set('x-request-path', request.nextUrl.pathname)
response.headers.set('x-request-method', request.method)
if (session) {
response.headers.set('x-session-id', session.id)
response.headers.set('x-user-id', session.userId)
}
awaitedCookies.set({
name: 'requestId',
value: requestId,
httpOnly: false,
})
return response
}export async function getLogger(): Promise<Logger> {
try {
const headerList = await headers()
return logger.child({
method: headerList.get('x-request-method'),
pathname: headerList.get('x-request-path'),
requestId: headerList.get('x-request-id'),
sessionId: headerList.get('x-session-id'),
userId: headerList.get('x-user-id'),
})
} catch (_error) {
// Dit kan enkel fout gaan als het request uitgevoerd wordt buiten een request (en headers dus niet bestaat).
logger.trace(_error)
return logger
}
}API Routes
De API-routes kunnen op exact dezelfde manier beveiligd worden, onderstaand voorbeeld bevat hier een voorbeeld voor. Een API-route die beveiligd is met een session cookie kan enkel gebruikt worden vanuit de Next-applicatie die op dezelfde origin draait. Als de API-routes aanspreekbaar moeten zijn vanuit een third-party applicatie, moet de route beveiligd worden met een JSON Web Token. Dit wordt volgende les besproken.
Voorbeeldcode
Uitgewerkt lesvoorbeeld met commentaar
De hashPassword functie die in het script gebruikt wordt, wordt verder in deze les besproken en geïmplementeerd.
Het seed script werkt wel omdat de startbestanden een hard gecodeerde waarde teruggeven in deze methode. ↩︎Via de argon2-ffi library kan Argon2 gebruikt worden in Node. We gebruiken deze library niet in deze cursus omdat deze heel wat extra configuratie vereist (zie node-gyp voor meer informatie). ↩︎
In een hex string worden enkel de volgende tekens gebruikt: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f ↩︎
Het is belangrijk dat het user id niet opgehaald wordt in de dal functies, maar doorgegeven wordt via een parameter. Als je het id zou ophalen in de dal functie, wordt het onmogelijk om een admin interface te maken die data van verschillende gebruikers kan aanpassen. ↩︎