6. JSON Web Tokens
Vorige les hebben we klassieke sessions cookies gebruikt om user authenticatie te voorzien. Alhoewel dit een goede aanpak is, is deze minder geschikt voor een op microservices gebaseerde architectuur en al helemaal niet als je single-sign-on (SSO) wilt implementeren. Deze les bespreken we een alternatieve aanpak aan de hand van JSON Web Tokens.
Startbestanden
Waarschuwing
In deze les wordt de algemene structuur besproken. Om het lesvoorbeeld behapbaar te houden, worden bepaalde dingen overgeslagen of versimpeld. Deze les heeft als doel het illustreren van het nut van JSON Web Tokens, en het illustreren van het SSO-concept.
Implementeer dit niet zomaar in je eigen server, net zoals voor les 5 is het in alle situaties aan te raden om een grondig geteste library/service te gebruiken zoals passportJS, auth0, workOS, ...
Onderstaande les implementeert de OpenID Connect of SAML-standaarden voor authenticatie niet, nog wordt OAuth 2.0 gebruikt om authenticatie te implementeren. Deze standaarden zijn duidelijk beschreven en grondig getest in verschillende RFC's (request for comments)[1].
JSON Web Token
Een JSON Web Token (JWT) is een standaard voor het doorgeven van data tussen twee of meerdere partijen. Deze data is cryptografisch gehandtekend[2] en de inhoud kan dus geverifieerd worden zodat je zeker bent dat er geen wijzigingen gemaakt zijn aan de token.
Een JWT is stateless, dat betekent dat de server nergens informatie moet bijhouden over de JWT, zelfs niet wie de eigenaar van de token is. De eigenaar van de token wordt toegevoegd in payload en/of de claims in de token en omdat de token cryptografisch ondertekend is, kan de server met zekerheid zeggen dat de data in de token geldig, correct en niet aangepast is.
Naast het uitwisselen van data, kan een JWT ook gebruikt worden voor authenticatie, dit is waar we ons in deze les op zullen focussen. Als een gebruiker inlogt, krijgt deze een JWT terug van de server, de token bevat bepaalde gegevens die de gebruiker identificeren, in de meeste gevallen gaat dit minimaal om een e-mailadres. Als de client een verzoek naar de server stuurt, wordt de JWT toegevoegd aan het request[3]. De server verifieert de handtekening en geeft de gebruiker vervolgens toegang op basis van deze claims, een claim kan bijvoorbeeld de rol van de gebruiker aanduiden.
JWT's vs. klassieke sessies
Klassieke sessions vereisen een database[4] waarin de sessies bewaard worden. Het probleem met deze aanpak is dat er voor elk request een verzoek naar de database verstuurd moet worden omdat het sessieId anders niet gekoppeld kan worden aan een gebruiker.
In een klassieke architectuur is dit geen probleem, als de webserver en de database server op één machine gehost worden, is een read operatie zeer snel. Als er echter gebruik gemaakt wordt van een microservice architectuur waarbij verschillende kleine stukken van de applicatie gedeployed zijn op afzonderlijkere servers, is een klassieke sessie niet meer toereikend. Als service B de gebruiker moet authenticeren aan de hand van een cookie met een sessionId, moet een HTTP request verstuurd worden naar de authentication server A. Aangezien dit via HTTP/TLS moet gebeuren, is er hier een grote overhead voor nodig.
Een JWT daarentegen kan verdeeld worden vanuit de authentication server, en gevalideerd worden op de verschillende microservices zonder dat hier een extra HTTP request voor nodig is.
Single Sign On
JWT's worden ook veelvuldig gebruikt binnen single sign on (SSO) systemen. In zo'n systeem worden gebruikers op verschillende services geredirect naar een identity provider zoals Google, Meta, GitHub, Microsoft, X, ...
De gebruiker logt hier in en wordt vervolgens geredirect naar de service provider met een JWT. De JWT bevat gebruikersgegevens die uitgelezen worden op de service provider, op basis van het e-mailadres in de JWT wordt dan meestal een account aangemaakt in de database van de service provider. De identity provider geeft slechts de absolute basisinformatie terug, als je meer wilt doen dan gebruikers identificeren, is het nodig om de gegevens te bewaren in je eigen database.
Eventueel worden er verschillende SSO-providers aangeboden, afhankelijk van de service wordt elke provider als een aparte account gezien, zelfs al is het e-mailadres hetzelfde, of worden accounts samengevoegd op basis van het e-mailadres.
Een goed voorbeeld van een SSO service is Google. Google gebruikt is niet enkel een SSO-provider voor derde partijen, maar fungeert ook als een SSO-provider voor zichzelf. Als je ingelogd bent op Google (google.com) ben je ook ingelogd op YouTube, Gmail, Google Play, Firebase, ...
API Beveiliging
Verschillende API's worden beveiligd door middel van JSON Web Tokens. In dit geval worden de JWT's gegenereerd via een webinterface en vervolgens wordt de token toegevoegd via HTTP headers aan elk request naar de API. Omdat de token dient als authenticatie voor de API en omdat hier geen gebruikersgegevens aan gekoppeld zijn, is de token zeer lang tot oneindig geldig. Dit soort tokens zijn normaal niet bedoeld om client-side gebruikt te worden.
Nadelen van JWT's
Authenticatie via JWT's heeft veel voordelen, maar ook verschillende belangrijke nadelen. Omdat een JWT volledig aan de client side leeft, is het onmogelijk om een gebruiker uit te loggen op alle toestellen. De token is ondertekend en zal steeds als geldig beschouwd worden, ook al is de gebruiker uitgelogd op de identity provider.
Om dit probleem tegen te gaan, wordt er typisch gebruikt gemaakt van twee tokens.
De access-token geeft de gebruiker toegang tot de applicatie, deze token is short lived, typisch tussen de 10 en 60 minuten. Hoe lang de token geldig is, hangt natuurlijk af hoe belangrijk de beveiliging van de applicatie is, als je het KU Loket vergelijkt met Netflix, is het duidelijk dat de access-token voor de eerste applicatie veel minder lang geldig mag zijn dan een token voor de tweede applicatie.
De refresh-token wordt gebruikt om een nieuwe access-token aan te vragen, deze token heeft een veel langere levensduur, vergelijkbaar met de levenduur van een sessie in les 5. Als de access-token vervallen is, zal de applicatie de gebruiker redirecten naar de authentication server en vervolgens genereert de authentication server een nieuwe access-token (als de gebruiker nog ingelogd is). Tenslotte redirect de authentication server terug naar de application server, deze redirect bevat dan natuurlijk de nieuwe access-token.
Waarschuwing
Als je slechts één server hebt, geen gebruik maakt van microservices en de API enkel voor eigen gebruik bouwt, is er geen enkele reden om gebruik te maken van JWT's.
Structuur van een JWT
De structuur van een JWT bestaat uit 3 delen, die van elkaar gescheiden worden door een punt.
- Header
- Payload
- Signature
Deze structuur is herkenbaar in onderstaande JWT (newline toegevoegd voor de duidelijkheid).
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJlbWFpbCI6InNlYmFzdGlhYW4uaGVuYXUzQHRob21hc21vcmUuYmUiLCJyb2xlIjoiY3VzdG9tZXIiLCJpZCI6Ijg3ZWI0YTQ3LTA5ZWMtNGI3ZS04Zjg0LWUzZGFiOWQ2MzkxNyIsImlhdCI6MTcwMTM1MjkwNiwiZXhwIjoxNzAxMzUzODA2LCJzdWIiOiI4N2ViNGE0Ny0wOWVjLTRiN2UtOGY4NC1lM2RhYjlkNjM5MTcifQ.
rhNydMXiClgRDHVAo_kLkYuWjbCub8PSYz8i-cWMexYDe data is gecodeerd is base64 zodat deze veilig kan doorgegeven worden via een URL[5].
De header bevat maar weinig informatie, enkel het algoritme dat gebruikt is om de handtekening te genereren en het type van de token worden bewaard.
De payload bevat enkele registered claims (iat, exp en sub), daarnaast worden er ook enkele custom claims toegevoegd (role, id, email). De registered claims en hun betekenis zijn gedefinieerd in RFC 7519, de private claims worden gekozen door de gebruiker. Merk op dat de registered claim slechts drie letters lang zijn, dit is bewust gedaan omdat een JWT zo klein mogelijk moet zijn. Via de iat claim wordt aangegeven wanneer de token aangemaakt is, via de exp claim wordt aangegeven wanneer de token vervalt en via de sub claim wordt het id van de gebruiker voor wie de token uitgedeeld is meegegeven. In dit geval voegen we het id van de gebruiker toe in de claim, maar als de token uitgedeeld is door een SSO-server kan dit bijvoorbeeld het e-mailadres van de gebruiker zijn.
Voor de private claim worden langere namen gebruikt, dit is een keuze die hier gemaakt wordt en is niet vereist, aangeraden of afgeraden door de JSON Web Token standaard.
{
"alg": "HS256",
"typ": "JWT"
}{
"email": "sebastiaan.henau@thomasmore.be",
"role": "customer",
"id": "232fa73a-8204-425e-ad7c-0167ba9f087e",
"iat": 1701352998,
"exp": 1701353898,
"sub": "232fa73a-8204-425e-ad7c-0167ba9f087e"
}JWT's in Express
We bouwen in deze les een applicatie die bestaat uit twee servers. De authentication server draait op poort 4000 en bevat enkel routes om een account aan te maken, te verwijderen en om in/uit te loggen. Op poort 3000 draait de ShopAPI uit de vorige les, alle authentication routes zijn hieruit verwijderd.
Voer voordat je het voorbeeld begint uit te bouwen de Prisma migrations uit in beide projecten. De database is aangepast ten opzichte van vorige les, de database URL's zijn aangepast zodat er twee aparte schema's gebruikt worden, één voor authentication en één voor de ShopAPI.
JWT Ondertekenen
Zoals hierboven besproken is het cruciaal dat een JWT ondertekend wordt, hiervoor moeten we een geheime sleutel genereren. Om de applicatie extra te beveiligen genereren we twee verschillende sleutels, één van de sleutels wordt gebruikt om een access-token te ondertekenen en de volgende sleutel om een refresh-token te ondertekenen. De sleutel die gebruikt wordt om de access-token te ondertekenen moet verdeeld worden naar alle servers die gebruik maken van de authentication server. De sleutel waarmee de refresh-token ondertekend wordt is enkel beschikbaar op de authentication server.
Er zijn verschillende manieren om dit te doen, maar de eenvoudigste is om gebruik te maken van de randomBytes functie die we in les 5 gebruikt hebben om een sessionIds te genereren.
Aangezien deze key natuurlijk persistent moet blijven doorheen verschillende keren dat de server gestart wordt, kunnen we deze niet generen in de code. In plaats hiervan starten we een interactieve Node shell in de terminal.
nodeVervolgens importeren we de randomBytes methode via de crypto bibliotheek. Merk op dat we hier gebruik moeten maken van CommonJS in de plaats van ES Modules. In les 1 hebben we package.json en tsconfig.json aangepast om het gebruikt van ES Modules mogelijk te maken, dit is natuurlijk niet mogelijk in de Node shell. Door onderstaande commando's één per één uit te voeren in de Node shell genereren we een geheime sleutel.
const {randomBytes} = require('crypto')
randomBytes(128).toString('hex')Bovenstaande code resulteert in een string van de vorm:
758c03e037cfeaa4d765420a666e6b6948d0f7d3bbd1f2081cc9b2fac6511ccc67311e54512157cd28e2f29bb59be5b164a1372e286d1f618ae3419c2df97519d7f5ea0d04dc97454813f08d378582bdac9e6e88e7b57f189e71576037c77a687aa718f16844449f7bb805c7d83c2cda2eadf27a29387cfbc9f857232aa5dc33Nu we deze twee sleutels gegenereerd hebben, kunnen we deze bewaren in de .env file.
DATABASE_URL="postgresql://postgres:postgresPassword@localhost:5432/postgres?schema=authentication"
JWT_SECRET=758c03e037cfeaa4d765420a666e6b6948d0f7d3bbd1f2081cc9b2fac6511ccc67311e54512157cd28e2f29bb59be5b164a1372e286d1f618ae3419c2df97519d7f5ea0d04dc97454813f08d378582bdac9e6e88e7b57f189e71576037c77a687aa718f16844449f7bb805c7d83c2cda2eadf27a29387cfbc9f857232aa5dc33
JWT_REFRESH_SECRET=d5ae2f0f83377a8f051a5c3a6368e5c914638a91ee1ee27f225afb4d6df7f5d60a2d8d8984f796ad97afe4daf4b1d87ef95f8be0a060911ae09d10c7fe3144fda4d24882211a564081e64dcf75fba8a99ff8247dfe8cdb3bab1774acc641641892ec93daa0f73dd0c9c3581ad1f03a2dbb156d36f50fed5ed42c1fc51af81281DATABASE_URL="postgresql://postgres:postgresPassword@localhost:5432/postgres?schema=public"
JWT_SECRET=758c03e037cfeaa4d765420a666e6b6948d0f7d3bbd1f2081cc9b2fac6511ccc67311e54512157cd28e2f29bb59be5b164a1372e286d1f618ae3419c2df97519d7f5ea0d04dc97454813f08d378582bdac9e6e88e7b57f189e71576037c77a687aa718f16844449f7bb805c7d83c2cda2eadf27a29387cfbc9f857232aa5dc33JWT aanmaken
Zoals eerder besproken is, moeten we twee verschillende tokens aanmaken, een access-token en een refresh-token. Hiervoor hebben we een nieuwe library nodig, jsonwebtoken.
pnpm add jsonwebtokennpm install jsonwebtokenbun add jsonwebtokenyarn add jsonwebtokenWe beginnen hieronder met het aanmaken van de refresh-token, onderstaande methode verwacht een Session object als argument omdat we moeten weten welke gebruiker eigenaar is van de sessie. We gebruiken het sessionId dan ook als payload voor de token.
Merk op dat we de levensduur instellen op één dag, net zoals de sessies in les 5.
Tenslotte plaatsen we de JWT in een cookie, hiervoor gebruiken we bijna dezelfde instelling als in de vorige les, het enige verschil is dat we een sameSite=none cookie gebruiken omdat we anders geen cookies kunnen versturen van shop.com naar auth.com. Dit brengt wel met zich mee dat het cookie enkel ingesteld kan worden via een HTTPS server, we kunnen dit dus niet testen via een browser. Via Postman is dit wel mogelijk.
import jwt from 'jsonwebtoken'
const refreshCookieName = 'refreshToken'
export function setRefreshCookie(res: Response, session: Session): void {
const refreshSecret = process.env.JWT_REFRESH_SECRET!
const refreshToken = jwt.sign(
{sessionId: session.id},
refreshSecret,
{
expiresIn: '1d',
subject: session.userId,
},
)
res.cookie(
refreshCookieName,
refreshToken,
{
httpOnly: true,
secure: true,
sameSite: 'none',
path: '/auth',
expires: session.activeUntil,
},
)
}De volgende methode wordt gebruikt om een access-token in te stellen. De token wordt op dezelfde manier aangemaakt als hierboven, met enkele kleine verschillen. De payload van de token bevat voor de access token geen sessionId, maar wel informatie over de gebruiker (email, rol en id). Verder wordt ook een kortere expiration date gebruikt, aangezien de token informatie bevat over rol van de gebruiker is het cruciaal dat dit token niet lang leeft en dat deze informatie in voldoende korte intervallen geverifieerd wordt bij de authentication server.
We bewaren deze token niet in een cookie, maar geven het mee als body in het antwoord dat de server op een login (of account create) verzoek terugstuurt. Dit is nodig omdat we de access-token in het verdere verloop van de les via een header doorgeven.
export function getAccessToken(user: UserWithRole): string {
const accessSecret = process.env.JWT_SECRET!
return jwt.sign(
{email: user.email, role: user.role.name, id: user.id},
accessSecret,
{
expiresIn: '15m',
subject: user.id,
},
)
}Vervolgens kunnen deze twee methodes opgeroepen worden in POST /accounts/create, POST /accounts/login routes.
accounts.post('/accounts', async (req: Request<void, string, IdOptional<User>>, res: Response<string>) => {
const [user, session] = await createUser(req.body)
const access = getAccessToken(user)
setRefreshCookie(res, session)
res.send(access)
})login.post('/login', async (req: Request<void, string, IdOptional<User>>, res: Response<string>) => {1
const user = await getUserByEmail(req.body.email)
const validCredentials = user && verifyPassword(user.password, req.body.password)
if (!validCredentials) {
res.statusCode = 400
return res.send('Invalid credentials')
}
const session = await startSession(user?.id as string)
const access = getAccessToken(user)
setRefreshCookie(res, session)
res.send(access)
})JWT verifiëren
Nu we kunnen inloggen, moeten we de sessionManagement, authenticatedRoute en adminRoute middleware functies aanpassen zodat de tokens gebruikt worden. Voordat we hieraan beginnen, breiden we het Request object van Express uit zodat de JWT hierin toegevoegd is.
import {Roles} from './roles.js'
export interface JwtPayload {
email: string
role: Roles
sub: string
exp: number
iat: number
}import {JwtPayload} from './jwtPayload.js'
declare global {
namespace Express {
export interface Request {
jwt: JwtPayload | null
}
}
}sessionManagement
We beginnen met de sessionManagement middleware functie uit te werken. De eerste stap is natuurlijk het uitlezen van de access-token. Hiervoor bekijken we de authorization header, via deze header geven we een bearer token door. Zoals de naam doet vermoeden is dit een token die de drager (bearer) identificeert. Deze header heeft onderstaande vorm:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cAls deze token niet gedefinieerd is, moet er verder niets gebeuren en kunnen we verder gaan naar de volgende middleware functie of route handler. De authenticatedRoute middleware functie kan dan gebruikt worden om bepaalde routes af te schermen als de access-token niet beschikbaar is.
In het geval dat de token aanwezig is, moet deze gevalideerd worden. Hiervoor kunnen we de verify functie uit de jsonwebtoken bibliotheek gebruiken. Als de handtekening niet overeen komt met de sleutel in de omgevingsvariabelen, of als de vervaldatum in het verleden ligt, wordt de token afgewezen. In dat geval geven we de unauthorized status code terug en blokkeren we de gebruiker zelfs van de niet beveiligde routes. Aangezien de token automatisch uit de cookies verwijderd wordt als de token vervallen is, is het onmogelijk dat dit een reden is om de token af te wijzen. De enige reden dat de token afgewezen kan worden, is dat een kwaadwillige gebruiker deze aangepast heeft en dat is een gebruiker die we willen blokkeren uit de app.
Merk op dat we de verval- en uitgiftedatum aanpassen, dit is nodig omdat deze door de jsonwebtoken library bewaard wordt als een Unix timestamp (aantal seconden sinds 01/01/1970) in de plaats van een JavaScript timestamp (aantal milliseconden sinds 01/01/1970).
import jwt from 'jsonwebtoken'
export async function sessionManagement(req: Request, res: Response, next: NextFunction) {
const accessToken = req.headers.authorization?.split('Bearer ')?.at(1)
if (!accessToken) {
return next()
}
try {
req.jwt = jwt.verify(accessToken, process.env.JWT_SECRET!) as JwtPayload
req.jwt.exp = req.jwt.exp * 1000
req.jwt.iat = req.jwt.iat * 1000
} catch (error) {
return res.sendStatus(401)
}
next()
}authenticatedRoute
Nu dat de access-token uitgelezen is en toegevoegd is aan de Request interface, kunnen we deze gebruiken om de authenticatedRoute middleware functie aan te passen.
export async function authenticatedRoute(req: Request, res: Response, next: NextFunction) {
if (!req.jwt) {
return res.sendStatus(401)
}
next()
}adminRoute
Om de rol van de gebruiker te verifiëren moeten we enkel de payload van de access-token uitlezen.
export async function adminRoute(req: Request, res: Response, next: NextFunction) {
if (req.jwt?.role === Roles.admin) {
next()
} else {
res.sendStatus(403) // Forbidden
}
}Naast een werkende validatie, toont de onderstaande video ook hoe een bearer token toegevoegd kan worden in een HTTP request via Postman.
Refresh token gebruiken
Momenteel vertoont de applicatie nog één groot probleem, na 15 minuten wordt de access-token verwijderd en heeft de gebruiker geen toegang meer tot de applicatie.
De gebruiker moet een nieuwe token aanvragen. In een SSO en/of OAuth situatie gebeurt dit normaal gezien automatisch, op de achtergrond. In deze les moet de token manueel ververst worden.
De POST /auth/refresh route moet zowel de sessie in de database verlengen als het refresh cookie aanpassen. Typisch gezien worden refresh-token slechts één keer gebruikt, zo is de kans op diefstal het kleinst. We laten het toevoegen van een validatiesysteem dat dit bijhoudt aan de geïnteresseerde lezer.
refresh.post('/refresh', async (req: Request, res: Response) => {
const [session, user] = await Promise.all([
extendSession(
req.refreshToken?.sessionId as string,
new Date((req.refreshToken?.exp as number) * 1000 + 15 * 60 * 1000),
),
getUser(req.refreshToken?.sub as string),
])
setRefreshCookie(res, session)
const access = getAccessToken(user!)
res.send(access)
})Voorbeeldcode
Uitgewerkt lesvoorbeeld met commentaar
Request for Comments zijn documenten die protocollen zoals HTTP(S), FTP, SAML, OAuth, SSH, UDP, TCP ... beschrijven. Deze documenten worden opgebouwd aan de hand van discussie en verschillende publicaties waar commentaar op gegeven kan worden. De volledige verzameling van RFC documenten is te vinden op de site van The Internet Engineering Task Force. De term wordt niet enkel gebruikt voor protocollen, maar ook voor nieuwe features in frameworks zoals React, in dit geval zijn de RFC's te vinden op GitHub. ↩︎
De handtekening kan via een symmetrische sleutel via HMAC of via een private key die dan geverifieerd kan worden met een publieke sleutel. ↩︎
In een cookie of via de authorization header (bearer token). ↩︎
Dit kan een klassieke relationele database zijn of een in-memory database zoals Redis. ↩︎
Base64 bevat enkel karakters die in een URL kunnen zitten dus geen tekens zoals een slash of een spatie. ↩︎