From 32729e72a3b3cb06227b8761a3cb084a6b4ad230 Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 26 May 2026 11:03:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(auth):=20refresh-token=20rotation=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=B4=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=BD=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B8=D1=80=D1=83=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B9=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два бага в refresh-flow, из-за которых утёкший refresh-token давал доступ после ротации (auth-edge step03): 1. AuthorizationController прокидывал в новый principal только AuthorizationId, но не TokenId. Handler RedeemTokenEntry читает TokenId из подписываемого principal, чтобы пометить погашаемый refresh как Redeemed — без него старый токен оставался valid. 2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный reuse-leeway: погашенный refresh ещё принимается в этом окне. Обнуляем окно (SetRefreshTokenReuseLeeway(TimeSpan.Zero)) — ротация инвалидирует старый refresh сразу. auth-edge: 10/10 (было 9/10). Co-Authored-By: Claude Opus 4.7 --- .../Controllers/AuthorizationController.cs | 14 ++++++++++++++ src/food-market.api/Program.cs | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs index bab91df..eb032ba 100644 --- a/src/food-market.api/Controllers/AuthorizationController.cs +++ b/src/food-market.api/Controllers/AuthorizationController.cs @@ -71,6 +71,20 @@ public async Task Exchange() if (rejection is not null) return rejection; var principal = await CreatePrincipal(user, request.GetScopes()); + // Прокидываем внутренние OpenIddict-claims погашаемого refresh-token + // в новый principal. Ключевой — TokenId: handler RedeemTokenEntry + // читает его из подписываемого principal, чтобы понять, какой именно + // refresh-token пометить Redeemed. Без него ротация не гасит старый + // токен — он остаётся valid и допускает повторное использование + // (одна утечка refresh = вечный доступ). AuthorizationId связывает + // новый токен с той же авторизацией, чтобы не плодить дубли. + var tokenId = info.Principal?.GetTokenId(); + if (!string.IsNullOrEmpty(tokenId)) + principal.SetTokenId(tokenId); + var authzId = info.Principal?.GetAuthorizationId(); + if (!string.IsNullOrEmpty(authzId)) + principal.SetAuthorizationId(authzId); + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index bc42e06..7f6f297 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -101,6 +101,13 @@ builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00")); opts.SetRefreshTokenLifetime(TimeSpan.Parse( builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00")); + // Rolling refresh включён по умолчанию: каждый refresh гасит старый + // токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт + // 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё + // принимается (для гонок/ретраев). Для розничной админки это дыра: + // утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно — + // ротация инвалидирует старый refresh немедленно. + opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero); // Явный Issuer = публичный URL админки. Без него OpenIddict // вычисляет issuer из текущего HTTP-запроса, что ломается за // nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём