193 lines
6.5 KiB
TypeScript
193 lines
6.5 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import { DateTime } from 'luxon'
|
|
import RefreshToken from '#models/refresh_token'
|
|
import User from '#models/user'
|
|
import env from '#start/env'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
|
|
export const REFRESH_COOKIE_NAME = 'rubis_refresh'
|
|
|
|
/**
|
|
* Génère un token plain (32 bytes random → base64url ~43 chars), retourne
|
|
* { plain, hashed } pour ne stocker que le hashed côté DB.
|
|
*
|
|
* SHA-256 suffit : le token est un opaque random non humain, pas un mot
|
|
* de passe — pas besoin de bcrypt/argon (contrairement aux passwords).
|
|
*/
|
|
function generateToken(): { plain: string; hashed: string } {
|
|
const plain = crypto.randomBytes(32).toString('base64url')
|
|
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
|
|
return { plain, hashed }
|
|
}
|
|
|
|
function hashToken(plain: string): string {
|
|
return crypto.createHash('sha256').update(plain).digest('hex')
|
|
}
|
|
|
|
function ttlDays(): number {
|
|
return env.get('REFRESH_TOKEN_TTL_DAYS', 30)
|
|
}
|
|
|
|
/**
|
|
* Pose le cookie httpOnly avec le token plain. Le SPA ne peut pas le lire
|
|
* en JS — c'est ce qui le protège du XSS, contrairement à localStorage.
|
|
*
|
|
* `path: /api/v1/auth` : le browser n'envoie le cookie qu'aux endpoints
|
|
* d'auth, pas sur chaque requête API. Réduit la surface d'attaque CSRF.
|
|
*
|
|
* `sameSite: 'lax'` (et pas 'strict') : `strict` bloque l'envoi du cookie
|
|
* sur les navigations top-level (URL tapée, bookmark, restauration
|
|
* d'onglets) — surtout Safari iOS — et casse le bootstrap SPA après
|
|
* fermeture/réouverture du navigateur. Lax envoie le cookie sur les
|
|
* top-level GET (qui sont safe) tout en bloquant les requêtes cross-site
|
|
* embedded → protection CSRF préservée pour le POST /auth/refresh.
|
|
*/
|
|
function setRefreshCookie(ctx: HttpContext, plain: string) {
|
|
const maxAgeSeconds = ttlDays() * 24 * 60 * 60
|
|
ctx.response.cookie(REFRESH_COOKIE_NAME, plain, {
|
|
httpOnly: true,
|
|
secure: env.get('COOKIE_SECURE', false),
|
|
sameSite: 'lax',
|
|
path: '/api/v1/auth',
|
|
domain: env.get('COOKIE_DOMAIN') || undefined,
|
|
maxAge: maxAgeSeconds,
|
|
})
|
|
}
|
|
|
|
function clearRefreshCookie(ctx: HttpContext) {
|
|
ctx.response.clearCookie(REFRESH_COOKIE_NAME, {
|
|
path: '/api/v1/auth',
|
|
domain: env.get('COOKIE_DOMAIN') || undefined,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Crée un refresh token pour un user et pose le cookie correspondant.
|
|
* Appelé après signup et login.
|
|
*/
|
|
export async function issueRefreshToken(
|
|
user: User,
|
|
ctx: HttpContext
|
|
): Promise<{ token: RefreshToken; plain: string }> {
|
|
const { plain, hashed } = generateToken()
|
|
|
|
const token = await RefreshToken.create({
|
|
userId: user.id,
|
|
hashedToken: hashed,
|
|
expiresAt: DateTime.now().plus({ days: ttlDays() }),
|
|
lastUsedAt: null,
|
|
revokedAt: null,
|
|
ipAddress: ctx.request.ip(),
|
|
userAgent: ctx.request.header('user-agent') ?? null,
|
|
})
|
|
|
|
setRefreshCookie(ctx, plain)
|
|
return { token, plain }
|
|
}
|
|
|
|
/**
|
|
* Fenêtre de grâce après révocation pendant laquelle on tolère la
|
|
* réutilisation d'un token révoqué SI un successeur actif existe — pour
|
|
* accommoder les courses entre onglets (deux refresh parallèles avec le
|
|
* même cookie). Au-delà : présomption de vol → panic mode.
|
|
*/
|
|
const ROTATION_GRACE_SECONDS = 60
|
|
|
|
/**
|
|
* Valide le cookie reçu et révoque l'ancien token. Retourne le user
|
|
* authentifié — le contrôleur appelle ensuite `issueRefreshToken` (via
|
|
* emitAuthSession) pour poser un nouveau cookie. Rotation complète.
|
|
*
|
|
* Si le user envoie un token déjà révoqué :
|
|
* - et qu'un successeur actif a été créé pour le même user dans les
|
|
* {ROTATION_GRACE_SECONDS} dernières secondes → c'est très probablement
|
|
* un refresh parallèle d'un autre onglet, pas un vol. On rotate ce
|
|
* successeur et on retourne la session normalement (les 2 onglets
|
|
* restent connectés).
|
|
* - sinon, c'est une réutilisation tardive → présomption de vol, on
|
|
* coupe TOUS les tokens actifs du user (panic mode). Le vrai
|
|
* propriétaire devra se re-logger.
|
|
*/
|
|
export async function consumeRefreshToken(
|
|
ctx: HttpContext
|
|
): Promise<{ user: User } | { errorCode: 'no_session' | 'session_expired' }> {
|
|
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
|
|
if (!cookie) return { errorCode: 'no_session' }
|
|
|
|
const hashed = hashToken(cookie)
|
|
const stored = await RefreshToken.query().where('hashed_token', hashed).first()
|
|
|
|
if (!stored) {
|
|
clearRefreshCookie(ctx)
|
|
return { errorCode: 'session_expired' }
|
|
}
|
|
|
|
// Token révoqué : check pour course entre onglets avant de paniquer.
|
|
if (stored.revokedAt) {
|
|
const graceCutoff = stored.revokedAt.plus({ seconds: ROTATION_GRACE_SECONDS })
|
|
const successor = await RefreshToken.query()
|
|
.where('user_id', stored.userId)
|
|
.whereNull('revoked_at')
|
|
.where('expires_at', '>', DateTime.now().toSQL())
|
|
.where('created_at', '>=', stored.revokedAt.toSQL())
|
|
.where('created_at', '<=', graceCutoff.toSQL())
|
|
.orderBy('created_at', 'desc')
|
|
.first()
|
|
|
|
if (successor) {
|
|
// Course détectée — rotate le successeur et continue normalement.
|
|
successor.revokedAt = DateTime.now()
|
|
successor.lastUsedAt = DateTime.now()
|
|
await successor.save()
|
|
const user = await User.findOrFail(successor.userId)
|
|
return { user }
|
|
}
|
|
|
|
// Vrai vol présumé : tout révoquer.
|
|
await revokeAllForUser(stored.userId)
|
|
clearRefreshCookie(ctx)
|
|
return { errorCode: 'session_expired' }
|
|
}
|
|
|
|
if (stored.expiresAt < DateTime.now()) {
|
|
stored.revokedAt = DateTime.now()
|
|
await stored.save()
|
|
clearRefreshCookie(ctx)
|
|
return { errorCode: 'session_expired' }
|
|
}
|
|
|
|
stored.revokedAt = DateTime.now()
|
|
stored.lastUsedAt = DateTime.now()
|
|
await stored.save()
|
|
|
|
const user = await User.findOrFail(stored.userId)
|
|
return { user }
|
|
}
|
|
|
|
/**
|
|
* Révoque le token courant (utilisé par /account/logout).
|
|
* Pas de panic — l'user demande explicitement la déconnexion.
|
|
*/
|
|
export async function revokeCurrentRefreshToken(ctx: HttpContext): Promise<void> {
|
|
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
|
|
if (cookie) {
|
|
const hashed = hashToken(cookie)
|
|
await RefreshToken.query()
|
|
.where('hashed_token', hashed)
|
|
.whereNull('revoked_at')
|
|
.update({ revoked_at: DateTime.now().toSQL() })
|
|
}
|
|
clearRefreshCookie(ctx)
|
|
}
|
|
|
|
/**
|
|
* Révoque tous les tokens d'un user (panic mode si vol détecté, ou
|
|
* appelable par "déconnecter toutes mes sessions").
|
|
*/
|
|
export async function revokeAllForUser(userId: string): Promise<void> {
|
|
await RefreshToken.query()
|
|
.where('user_id', userId)
|
|
.whereNull('revoked_at')
|
|
.update({ revoked_at: DateTime.now().toSQL() })
|
|
}
|