diff --git a/PROJECT_SNAPSHOT.md b/PROJECT_SNAPSHOT.md index 6328ec7a..a557be90 100644 --- a/PROJECT_SNAPSHOT.md +++ b/PROJECT_SNAPSHOT.md @@ -113,12 +113,29 @@ be treated as "someone already fixed this." pre-existing `tokens/old`) now **cryptographically verifies its HS256 signature** against a new `Core:JwtSigningSecret` (real key, obtained from the gama-api team, not yet populated anywhere) — closing a real forgeable-token gap that existed in `tokens/old` before this change and that an - earlier revision of this bridge would have inherited/widened. Trade-off: a legacy-bridge session - can't be revoked early via `tokens/revoke` (JWTs are stateless) and its lifetime is governed by - gama-api's own token expiry, not this app's configurable token lifespan. `register`/`recovery` are - pure passthroughs (gama-api never returns a token for those flows). Entirely temporary — this - whole bridge, plus the - pre-existing `tokens/old`, is meant to be deleted once the frontend fully migrates off gama-api. + earlier revision of this bridge would have inherited/widened. Trade-off: `tokens/revoke` (this + backend's own store) can't touch a legacy-bridge session, since JWTs are stateless here — use + the bridge's own `GET logout` instead (added 2026-07-13, see below) to end one early. Session + lifetime is otherwise governed by gama-api's own token expiry, not this app's configurable token + lifespan. `register`/`recovery` are pure passthroughs (gama-api never returns a token for those + flows). Entirely temporary — this whole bridge, plus the pre-existing `tokens/old`, is meant to + be deleted once the frontend fully migrates off gama-api. +- **Legacy-auth bridge logout added** (2026-07-13 — see + [`docs/api/authentication.md`](docs/api/authentication.md)'s "Legacy-auth bridge" section): + `GET legacy-auth/logout` proxies gama-api's own `GET /users/logout` (`Core:Logout` config, + bearer-auth), relaying the caller's raw legacy JWT straight from the `Authorization` header. This + is the one legacy-bridge operation that *does* end a session early, closing the gap called out in + the entry above. +- **Legacy-auth bridge logout blocklist added** (2026-07-14 — see + [`docs/api/authentication.md`](docs/api/authentication.md)): the 2026-07-13 logout endpoint above + only ended the session on gama-api's side — `ValidateLegacyJwtAsync` validates signature/issuer/ + audience/expiry entirely offline, with no way to know a token was just logged out, so the same + JWT kept authenticating against *this* backend until its own `exp` naturally lapsed. Fixed by + having `IdentityService.LegacyLogoutAsync` write the token (SHA-256-hashed, not raw) to + `ICacheProvider`/Redis on a successful proxy logout, TTL'd to the token's own remaining lifetime; + `VerifyLegacyTokenAsync` (per-request auth) and `GenerateTokenByCoreTokenAsync` (`tokens/old`) + both check that blocklist right after signature validation. `SyncLegacyAuthAsync` (login/google) + intentionally doesn't check it — a fresh login token can't already be blocklisted. - **Quota-based subscription system built** (2026-07-10, phase 1 — see [`docs/business/subscriptions.md`](docs/business/subscriptions.md)): `SubscriptionPlan` no longer carries a price — pricing moved to `SubscriptionPlanPrice` (regional-pricing-ready, diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 85e8eb29..17ebc9fa 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -124,6 +124,21 @@ alongside `tokens/old` above — once the frontend fully migrates. (`type`: `request`/`resend_code`/`confirm`/final), and neither ever returns a token at any step (`{"status":1,"data":{"message":"done"}}` even on the final step) — the frontend calls `login` afterward to actually get a session, which is where sync happens. +- `GET logout` proxies gama-api's `GET /users/logout` (`ICoreProvider.LegacyLogoutAsync`, + `Core:Logout` config) — the caller's raw legacy JWT is read straight from the incoming + `Authorization` header (`TokenAuthenticationHandler.GetTokenFromHeader`) and relayed unchanged as + gama-api's own `bearerAuth`. Unlike register/recovery, this is not a pure passthrough on the + local side: on a successful proxy call, `IdentityService.BlockLegacyTokenAsync` writes the + token to a local blocklist (`ICacheProvider`/Redis, keyed by a SHA-256 hash of the token, not the + raw token) with a TTL equal to the token's own remaining `exp`. `ValidateLegacyJwtAsync` only + checks signature/issuer/audience/expiry and has no way to know gama-api ended the session + server-side, so without this the same JWT would otherwise keep authenticating against this + backend until it naturally expired even after logout. `VerifyLegacyTokenAsync` (per-request auth) + and `GenerateTokenByCoreTokenAsync` (`tokens/old`) both check the blocklist via + `IsLegacyTokenBlockedAsync` immediately after signature validation; `SyncLegacyAuthAsync` + (login/google) does not, since a fresh login always gets a brand-new token from gama-api that + can't already be in the blocklist. This is the one legacy-bridge operation that **does** end a + session early on both sides, unlike the trade-off described below for `tokens/revoke`. **Why no wrapping.** The natural design would be to mint a gamatrain-back token and hand back some combination of the two. Instead, gamatrain-back adapts to gama-api's token instead of the other way @@ -153,8 +168,12 @@ session): `IdentityOptions:Tokens:ApiDataProtectorTokenProviderOptions:TokenLifespan` that governs normal opaque-token sessions. - **`tokens/revoke` cannot end a legacy-bridge session early.** JWTs are self-contained/stateless — - there is no server-side store to invalidate. This only affects sessions started via - `legacy-auth/login`/`google`; native opaque-token sessions revoke exactly as before. + there is no server-side store *here* to invalidate, and `tokens/revoke` only ever touches this + app's own opaque-token store. This only affects sessions started via `legacy-auth/login`/`google`; + native opaque-token sessions revoke exactly as before. Use **`GET legacy-auth/logout`** instead + for a legacy-bridge session — it proxies gama-api's own logout (ending the session there) *and* + writes the token to this backend's own short-lived blocklist (see above), so it's the one + operation that ends a legacy-bridge session on both sides at once. **Revocation** — `POST /api/v1/identities/tokens/revoke` (`[Permission(policy: null)]`, i.e. requires being authenticated first) invalidates the current token diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 9cb2262f..71ce11d5 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -168,6 +168,7 @@ string is parsed internally instead) — when `CoreId`, `id` is resolved against | POST | `google` | Proxy gama-api googleAuth; same sync behavior as `login` | Anonymous | `LegacyGoogleAuthRequestViewModel` (body) | `LegacyAuthTokenResponseViewModel` | | POST | `register` | Pure passthrough to gama-api register (multi-step OTP); no local sync, no token | Anonymous | `LegacyOtpFlowRequestViewModel` (body) | `LegacyMessageResponseViewModel` | | POST | `recovery` | Pure passthrough to gama-api recovery/reset-password (multi-step OTP); no local sync, no token | Anonymous | `LegacyOtpFlowRequestViewModel` (body) | `LegacyMessageResponseViewModel` | +| GET | `logout` | Proxies gama-api's `GET /users/logout`, relaying the caller's raw legacy JWT from the `Authorization` header; on success also records the token in a local blocklist (TTL = token's remaining `exp`) so this backend stops honoring it too | Anonymous (token supplied via header, validated by gama-api itself) | none (`Authorization: Bearer {gama-api JWT}` header) | `Void` | ### LanguagesController `src/Presentation/Api/Controllers/LanguagesController.cs` — class-level `[Permission(policy: null)]` + `[AllowAnonymous]` (whole controller anonymous) diff --git a/docs/business/identity-and-access.md b/docs/business/identity-and-access.md index 19f15eae..d94a829c 100644 --- a/docs/business/identity-and-access.md +++ b/docs/business/identity-and-access.md @@ -40,9 +40,9 @@ standard Identity join/claim/token tables). ## Legacy-auth bridge (temporary, migration-only) While gama-api (the old backend) is still in use, `LegacyAuthBridgeController` -(`api/v1/legacy-auth`) proxies its `login`/`register`/`recovery`/`googleAuth` flows so users who -only ever had an old-backend account can keep authenticating without a separate "migrate your -account" step. On a successful `login`/`google` call, `IdentityService.SyncLegacyAuthAsync` +(`api/v1/legacy-auth`) proxies its `login`/`register`/`recovery`/`googleAuth`/`logout` flows so +users who only ever had an old-backend account can keep authenticating without a separate "migrate +your account" step. On a successful `login`/`google` call, `IdentityService.SyncLegacyAuthAsync` (`IdentityService.cs`) links or creates the local `ApplicationUser`: 1. Look up by `CoreId` (the existing FK linking a local user to their old-backend id). @@ -63,7 +63,10 @@ never has to change anything and the frontend never has to know two backends are mechanism, its required `Core:JwtSigningSecret` (real signature verification, not optional — a forged token otherwise authenticates as any linked account), and its trade-offs (notably: a legacy-bridge session can't be revoked early via `tokens/revoke`, since it isn't backed by any -server-side token store). This whole bridge — +server-side token store here — `GET legacy-auth/logout` covers that case instead, by proxying +gama-api's own logout *and* recording the token in a local short-lived blocklist so this backend +also stops honoring it immediately, rather than only relying on gama-api-side state). This whole +bridge — controller, the `Legacy*` methods on `ICoreProvider`/`IIdentityService`, and `VerifyLegacyTokenAsync` — is temporary and will be removed once the frontend fully migrates off gama-api. diff --git a/src/Application/Interface/IIdentityService.cs b/src/Application/Interface/IIdentityService.cs index a781b9db..238e5e5c 100644 --- a/src/Application/Interface/IIdentityService.cs +++ b/src/Application/Interface/IIdentityService.cs @@ -79,6 +79,13 @@ public interface IIdentityService Task> LegacyGoogleAuthAsync([NotNull] LegacyGoogleAuthRequestDto requestDto); Task> LegacyRegisterAsync([NotNull] LegacyOtpFlowRequestDto requestDto); Task> LegacyRecoveryAsync([NotNull] LegacyOtpFlowRequestDto requestDto); + + /// + /// Ends a gama-api-issued session by proxying to gama-api's own GET /users/logout (bearerAuth) with the + /// caller's raw legacy JWT. Pure passthrough, same as LegacyRegisterAsync/LegacyRecoveryAsync - no local + /// state to update, since this backend never stores the legacy token in the first place. + /// + Task> LegacyLogoutAsync([NotNull] string token); } } diff --git a/src/Application/Service/IdentityService.cs b/src/Application/Service/IdentityService.cs index c4b57dae..20f44deb 100644 --- a/src/Application/Service/IdentityService.cs +++ b/src/Application/Service/IdentityService.cs @@ -42,6 +42,7 @@ namespace GamaEdtech.Application.Service using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; @@ -62,6 +63,7 @@ public partial class IdentityService(Lazy unitOfWorkProvide : LocalizableServiceBase(unitOfWorkProvider, httpContextAccessor, localizer, logger), IIdentityService, ITokenService, ISiteMapHandler { private const string RolesCacheKey = "Roles"; + private const string LegacyLogoutBlocklistCacheKeyPrefix = "LegacyLogoutBlocklist_"; public async Task>> GetUsersAsync(ListRequestDto? requestDto = null) { @@ -615,7 +617,7 @@ public async Task> GenerateUserTokenAsy try { var validation = await ValidateLegacyJwtAsync(token); - if (!validation.IsValid) + if (!validation.IsValid || await IsLegacyTokenBlockedAsync(token)) { return null; } @@ -691,6 +693,32 @@ private async Task ValidateLegacyJwtAsync(string? token) }); } + /// + /// Blocks a legacy JWT that GET legacy-auth/logout just ended on gama-api's side. ValidateLegacyJwtAsync + /// only checks signature/issuer/audience/expiry - it has no way to know gama-api already invalidated the + /// session server-side, so without this the same token would keep authenticating here until it naturally + /// expired. Keyed by a hash of the token (not the raw token) so it isn't sitting in the cache in plaintext; + /// TTL matches the token's own remaining lifetime, since it's a no-op once the token would fail expiry + /// validation anyway. + /// + private async Task BlockLegacyTokenAsync(string token) + { + var validation = await ValidateLegacyJwtAsync(token); + if (validation.IsValid && validation.SecurityToken is JsonWebToken jwt) + { + await cacheProvider.Value.SetAsync(BuildLegacyLogoutBlocklistCacheKey(token), true, new DistributedCacheEntryOptions + { + AbsoluteExpiration = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero), + }); + } + } + + private async Task IsLegacyTokenBlockedAsync(string? token) + => token is not null && await cacheProvider.Value.GetAsync(BuildLegacyLogoutBlocklistCacheKey(token)) == true; + + private static string BuildLegacyLogoutBlocklistCacheKey(string token) + => $"{LegacyLogoutBlocklistCacheKeyPrefix}{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)))}"; + public async Task> RemoveUserTokenAsync([NotNull] RemoveUserTokenRequestDto requestDto) { try @@ -1358,7 +1386,7 @@ public async Task> GenerateTokenByCoreT try { var data = await ValidateLegacyJwtAsync(requestDto.Token); - if (!data.IsValid) + if (!data.IsValid || await IsLegacyTokenBlockedAsync(requestDto.Token)) { return new(OperationResult.Failed) { @@ -1505,6 +1533,25 @@ public async Task> LegacyRecoveryAsync([Not } } + public async Task> LegacyLogoutAsync([NotNull] string token) + { + try + { + var result = await coreProvider.Value.LegacyLogoutAsync(new() { Token = token }); + if (result.OperationResult is OperationResult.Succeeded) + { + await BlockLegacyTokenAsync(token); + } + + return result; + } + catch (Exception exc) + { + Logger.Value.LogException(exc); + return new(OperationResult.Failed) { Errors = new[] { new Error { Message = exc.Message }, } }; + } + } + /// /// Shared by LegacyLoginAsync/LegacyGoogleAuthAsync (the only gama-api flows that return a token): decodes /// the legacy JWT to get CoreId/identity (same signature-skipping validation as GenerateTokenByCoreTokenAsync), diff --git a/src/Core/Data/Dto/Identity/LegacyLogoutRequestDto.cs b/src/Core/Data/Dto/Identity/LegacyLogoutRequestDto.cs new file mode 100644 index 00000000..5699b780 --- /dev/null +++ b/src/Core/Data/Dto/Identity/LegacyLogoutRequestDto.cs @@ -0,0 +1,7 @@ +namespace GamaEdtech.Data.Dto.Identity +{ + public sealed class LegacyLogoutRequestDto + { + public required string Token { get; set; } + } +} diff --git a/src/Infrastructure/Infrastructure/Provider/Core/CoreProvider.cs b/src/Infrastructure/Infrastructure/Provider/Core/CoreProvider.cs index 76e1e324..be82c766 100644 --- a/src/Infrastructure/Infrastructure/Provider/Core/CoreProvider.cs +++ b/src/Infrastructure/Infrastructure/Provider/Core/CoreProvider.cs @@ -22,6 +22,8 @@ namespace GamaEdtech.Infrastructure.Provider.Core using static GamaEdtech.Common.Core.Constants; + using Void = Common.Data.Void; + public sealed class CoreProvider(Lazy configuration, Lazy httpProvider, Lazy> localizer , Lazy> logger) : InfrastructureBase(httpProvider, localizer, logger), ICoreProvider @@ -365,6 +367,30 @@ public async Task> LegacyRecoveryAsync([Not } } + public async Task> LegacyLogoutAsync([NotNull] LegacyLogoutRequestDto requestDto) + { + try + { + var response = await HttpProvider.Value.GetAsync, IHttpRequest>(new() + { + Uri = configuration.Value.GetValue("Core:Logout"), + Request = null, + HeaderParameters = [("Authorization", $"Bearer {requestDto.Token}")], + }); + return response switch + { + null => new(OperationResult.Failed) { Errors = [new() { Message = Localizer.Value["GeneralError"], }] }, + { Status: 1 } => new(OperationResult.Succeeded) { Data = new() }, + _ => new(OperationResult.NotValid) { Errors = [new() { Message = response.Message ?? Localizer.Value["GeneralError"], }] }, + }; + } + catch (Exception exc) + { + Logger.Value.LogException(exc); + return new(OperationResult.Failed) { Errors = [new() { Message = exc.Message, }] }; + } + } + private async Task MapAuthResultAsync(CoreAuthUserInfoResponse? info, string jwtToken) { LegacyAuthResponseDto result = new() diff --git a/src/Infrastructure/Interface/ICoreProvider.cs b/src/Infrastructure/Interface/ICoreProvider.cs index d96ec29e..e1cd6cd7 100644 --- a/src/Infrastructure/Interface/ICoreProvider.cs +++ b/src/Infrastructure/Interface/ICoreProvider.cs @@ -23,5 +23,6 @@ public interface ICoreProvider Task> LegacyGoogleAuthAsync([NotNull] LegacyGoogleAuthRequestDto requestDto); Task> LegacyRegisterAsync([NotNull] LegacyOtpFlowRequestDto requestDto); Task> LegacyRecoveryAsync([NotNull] LegacyOtpFlowRequestDto requestDto); + Task> LegacyLogoutAsync([NotNull] LegacyLogoutRequestDto requestDto); } } diff --git a/src/Presentation/Api/Controllers/LegacyAuthBridgeController.cs b/src/Presentation/Api/Controllers/LegacyAuthBridgeController.cs index c2254dd6..6780f09b 100644 --- a/src/Presentation/Api/Controllers/LegacyAuthBridgeController.cs +++ b/src/Presentation/Api/Controllers/LegacyAuthBridgeController.cs @@ -8,6 +8,7 @@ namespace GamaEdtech.Presentation.Api.Controllers using GamaEdtech.Application.Interface; using GamaEdtech.Common.Core; using GamaEdtech.Common.Data; + using GamaEdtech.Common.Identity; using GamaEdtech.Presentation.ViewModel.Identity; using Microsoft.AspNetCore.Authorization; @@ -16,6 +17,8 @@ namespace GamaEdtech.Presentation.Api.Controllers using static GamaEdtech.Common.Core.Constants; + using Void = Common.Data.Void; + /// /// Temporary proxy to gama-api's login/register/recovery/googleAuth during the old-backend migration. login/google /// additionally sync the local user and hand back gama-api's own token unchanged - TokenAuthenticationHandler @@ -133,5 +136,38 @@ public async Task> Recovery([NotNu return Ok(new(new Error { Message = exc.Message })); } } + + /// + /// Ends the caller's gama-api session by proxying to gama-api's own GET /users/logout with their raw legacy + /// JWT. Pure passthrough, same as Register/Recovery - this backend never stored the token, so there's + /// nothing local to invalidate; gama-api itself validates and revokes it. Not to be confused with + /// IdentitiesController.Logout (Identity cookie) or tokens/revoke (opaque bearer token), neither of which + /// can end a legacy-bridge session - see authentication.md. + /// + [HttpGet("logout"), Produces(typeof(ApiResponse))] + public async Task> Logout() + { + try + { + var token = TokenAuthenticationHandler.GetTokenFromHeader(Request); + if (string.IsNullOrEmpty(token)) + { + return Ok(new(new Error { Message = "Missing Authorization token" })); + } + + var result = await identityService.Value.LegacyLogoutAsync(token); + + return Ok(new(result.Errors) + { + Data = result.Data, + }); + } + catch (Exception exc) + { + Logger.Value.LogException(exc); + + return Ok(new(new Error { Message = exc.Message })); + } + } } } diff --git a/src/Presentation/Api/appsettings.json b/src/Presentation/Api/appsettings.json index b2703fcd..30a02cb6 100644 --- a/src/Presentation/Api/appsettings.json +++ b/src/Presentation/Api/appsettings.json @@ -171,6 +171,7 @@ "Register": "https://core.gamatrain.com/api/v1/users/register", "Recovery": "https://core.gamatrain.com/api/v1/users/recovery", "GoogleAuth": "https://core.gamatrain.com/api/v1/users/googleAuth", + "Logout": "https://core.gamatrain.com/api/v1/users/logout", "JwtSigningSecret": "" }, "ApiKey": "kqR2GtIrpUrDZduvNwPTpQ8acHJQsQ2X0vK0e8GNkC9PFTv7EtWCaP0j0p2Y59lepGkik06cIbqB8W68KYolHaCuTIqCKD4ZokIURuH0hVCuyLQxtqZZwgwvusKdr1sQ",