SF

ASP.NET Core Identity · JWT · Sécurité

La session qu'on croyait fermée

Un refresh token rejoué, une session encore valide après un changement de mot de passe. Rotation atomique, détection de réutilisation, révocation en cascade.

1
usage par refresh token
CAS
remplacement atomique
0
session survivante au rejeu

Le problème

Un refresh token a une durée de vie longue par nature : c'est ce qui évite à l'utilisateur de se reconnecter toutes les quinze minutes. Mais cette même longévité en fait la cible la plus intéressante d'une application. S'il fuite — extension de navigateur, poste partagé, journal mal configuré — l'attaquant obtient un accès renouvelable indéfiniment. Pire encore : par défaut, changer son mot de passe n'invalide rien. Un utilisateur qui se sait compromis fait le geste qu'on lui a appris, et la session de l'attaquant continue de vivre.

L'approche

Le token devient à usage unique : chaque rafraîchissement en émet un nouveau et invalide l'ancien. Le remplacement se fait en compare-and-swap plutôt qu'en simple UPDATE — si deux requêtes concurrentes présentent le même token, la base tranche et la perdante est rejetée, au lieu que les deux repartent avec une session valide. Avant la rotation, le hash du token consommé est conservé : présenter à nouveau ce token n'est plus une erreur possible, c'est la signature d'un rejeu. Dans ce cas la réponse n'est pas de refuser la requête, mais de révoquer toute la famille de tokens de l'utilisateur, puisqu'on ne sait pas qui, du légitime ou de l'attaquant, détient encore quoi. En parallèle, le changement de mot de passe et la modification des rôles déclenchent une remise à zéro du security stamp, qui invalide les tokens en circulation.

Extrait de codeRefreshTokenService.cs
// A refresh token is single-use. Presenting one that was already
// consumed is not a mistake — it means someone is replaying a token they
// should no longer hold, so the correct answer is to burn the whole family.
var presentedHash = RefreshTokenHasher.Hash(presentedToken);

var userId = await userRepository.FindUserIdByAuthenticationTokenAsync(
    LoginProvider, RefreshTokenName, presentedToken, cancellationToken);

if (userId is null)
{
    // Unknown token: either forged, or a replay of one already rotated.
    await HandlePossibleRefreshTokenReuseAsync(presentedHash, cancellationToken);
    throw new UnauthorizedAccessException("Invalid or expired refresh token");
}

// Remember what was consumed, before rotating — otherwise a later replay
// of this exact token is indistinguishable from a forgery.
await authenticationService.SetAuthenticationTokenAsync(
    user, LoginProvider, PreviousRefreshTokenHashName, presentedHash, cancellationToken);

// Compare-and-swap, not a plain update. Two refreshes racing on the same
// token must not both succeed: the database decides the winner, and the
// loser is rejected rather than silently issued a second valid session.
var rotated = await userRepository.TryReplaceAuthenticationTokenValueAsync(
    user.Id,
    LoginProvider,
    RefreshTokenName,
    expectedValue: presentedToken,
    newValue: newRefreshToken,
    cancellationToken);

if (!rotated)
{
    // Lost the race, or already rotated elsewhere. Fail closed.
    throw new UnauthorizedAccessException("Refresh token already rotated");
}

Le résultat

Un token volé n'est exploitable qu'une seule fois, et cette unique utilisation déclenche la révocation de la session légitime — l'utilisateur est déconnecté, ce qui est précisément le signal qu'il doit recevoir. Le changement de mot de passe ferme réellement les sessions ouvertes. Et les rôles retirés à un utilisateur prennent effet immédiatement, sans attendre l'expiration naturelle du token.

Conformité · CQRS · MapperlySortir de MediatR et AutoMapper, en productionNext.js · Payload CMS · NeonSaaS solo : de Payload CMS au client réel