add plausible
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy Landing / build-and-deploy (push) Successful in 17s
Build & Deploy API / build-and-deploy (push) Successful in 1m20s

This commit is contained in:
ordinarthur 2026-05-08 13:08:07 +02:00
parent 05cfa598b2
commit 7c45ee4490
3 changed files with 60 additions and 5 deletions

View File

@ -34,13 +34,20 @@ function ttlDays(): number {
* *
* `path: /api/v1/auth` : le browser n'envoie le cookie qu'aux endpoints * `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. * 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) { function setRefreshCookie(ctx: HttpContext, plain: string) {
const maxAgeSeconds = ttlDays() * 24 * 60 * 60 const maxAgeSeconds = ttlDays() * 24 * 60 * 60
ctx.response.cookie(REFRESH_COOKIE_NAME, plain, { ctx.response.cookie(REFRESH_COOKIE_NAME, plain, {
httpOnly: true, httpOnly: true,
secure: env.get('COOKIE_SECURE', false), secure: env.get('COOKIE_SECURE', false),
sameSite: 'strict', sameSite: 'lax',
path: '/api/v1/auth', path: '/api/v1/auth',
domain: env.get('COOKIE_DOMAIN') || undefined, domain: env.get('COOKIE_DOMAIN') || undefined,
maxAge: maxAgeSeconds, maxAge: maxAgeSeconds,
@ -78,13 +85,28 @@ export async function issueRefreshToken(
return { token, 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 * Valide le cookie reçu et révoque l'ancien token. Retourne le user
* authentifié le contrôleur appelle ensuite `issueRefreshToken` (via * authentifié le contrôleur appelle ensuite `issueRefreshToken` (via
* emitAuthSession) pour poser un nouveau cookie. Rotation complète. * emitAuthSession) pour poser un nouveau cookie. Rotation complète.
* *
* Si le user envoie un token déjà révoqué, on suppose un vol potentiel * Si le user envoie un token déjà révoqué :
* et on révoque TOUS les tokens actifs du user (panic mode). * - et qu'un successeur actif a é 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( export async function consumeRefreshToken(
ctx: HttpContext ctx: HttpContext
@ -100,9 +122,28 @@ export async function consumeRefreshToken(
return { errorCode: 'session_expired' } return { errorCode: 'session_expired' }
} }
// Token déjà révoqué = signal de vol potentiel : on coupe tout pour // Token révoqué : check pour course entre onglets avant de paniquer.
// l'user concerné. Le vrai propriétaire devra se re-logger.
if (stored.revokedAt) { 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) await revokeAllForUser(stored.userId)
clearRefreshCookie(ctx) clearRefreshCookie(ctx)
return { errorCode: 'session_expired' } return { errorCode: 'session_expired' }

View File

@ -20,6 +20,13 @@
<meta name="apple-mobile-web-app-title" content="Rubis" /> <meta name="apple-mobile-web-app-title" content="Rubis" />
<title>Rubis Sur l'Ongle</title> <title>Rubis Sur l'Ongle</title>
<!-- Privacy-friendly analytics by Plausible (RGPD-friendly, no cookies) -->
<script async src="https://plausible.io/js/pa-zLenWxEI1wBQxOWaZrS94.js"></script>
<script>
window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} };
plausible.init()
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1900,6 +1900,13 @@
display: flex; display: flex;
} }
</style> </style>
<!-- Privacy-friendly analytics by Plausible (RGPD-friendly, no cookies) -->
<script async src="https://plausible.io/js/pa-zLenWxEI1wBQxOWaZrS94.js"></script>
<script>
window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} };
plausible.init()
</script>
</head> </head>
<body> <body>