using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using DigestAuthenticationOnWCF; using FrameworkBLL.KeyCloak; using FrameworkDAL.DTO.KeyCloak; using GB5Shared.Auth.Jwt; using GB5Shared.DTO.Framework.Authentication; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.DTO.Framework.SuccessORError; using GoodBooks.FrameworkBLL.Authentication; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; using FastEndpoints; using static GB5Shared.GB5Constant.Constant; namespace FrameworkSL.Endpoints.KeyCloak { // H-17 fix: migrated off MVC ControllerBase (was KeyCloakService.GetUserLogin under // [Route("Authorize")], route "AuthenticateUserToken") onto native FastEndpoints. Response // shape (ControllerResponseDTO, via the existing Response.ControllerXxx helpers) is // deliberately unchanged — this is a hosting-mechanism migration only, not a wire-contract // change, so the FE (login.service.ts) needs no update. public class AuthenticateUserToken : Endpoint> { private readonly IAuthenticationBLL _authenticationBLL; private readonly KeycloakOptions _keycloakOptions; private readonly MultiTenantOidcJwksCache _jwksCache; private readonly IMemoryCache _cache; private readonly ILogger _logger; private const string ONE_TIME_CODE_PREFIX = "kc_login_code_"; public AuthenticateUserToken( IAuthenticationBLL authenticationBLL, IOptions keycloakOptions, MultiTenantOidcJwksCache jwksCache, IMemoryCache cache, ILogger logger) { _authenticationBLL = authenticationBLL; _keycloakOptions = keycloakOptions.Value; _jwksCache = jwksCache; _cache = cache; _logger = logger; } public override void Configure() { Post("/Authorize/AuthenticateUserToken"); AllowAnonymous(); } private string GetMachineIP() { var httpContext = HttpContext; if (httpContext == null) return null!; string ip = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault()!; if (string.IsNullOrEmpty(ip)) ip = httpContext.Connection.RemoteIpAddress?.ToString()!; return ip; } public override async Task HandleAsync(LoginUserTokenDTO req, CancellationToken ct) { try { // req.UserToken carries the one-time code from KeyCloakLoginCallback (renamed in // meaning, not in wire shape, to avoid an FE/BE contract bump beyond what's already // required) — NOT a bare, unverifiable token blob like before. string cacheKey = ONE_TIME_CODE_PREFIX + req.UserToken; if (!_cache.TryGetValue(cacheKey, out KeycloakOneTimeLoginDTO? pending) || pending == null) { await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ControllerErrorResponse( "Login code is invalid or has expired. Please sign in again.", 401), 401, ct); return; } _cache.Remove(cacheKey); // single-use, redeemed regardless of what happens next if (!string.Equals(pending.Username, req.Username, StringComparison.OrdinalIgnoreCase) || !string.Equals(pending.Realm, req.Realm, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning( "AuthenticateUserToken: username/realm mismatch between cached login and request (realm '{Realm}')", req.Realm); await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ControllerErrorResponse("Invalid User", 401), 401, ct); return; } // The real check: cryptographically verify the cached access token against this // tenant's Keycloak realm JWKS — this is what makes it impossible to reach // AuthenticateUserViaKeyCloak without having actually completed a real Keycloak // login, closing the C-5 bypass. try { var (keys, issuer) = await _jwksCache.GetSigningKeysAsync(_keycloakOptions.KeycloakHost, pending.Realm); var handler = new JwtSecurityTokenHandler(); var validationParams = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeys = keys, ClockSkew = TimeSpan.FromSeconds(30) }; ClaimsPrincipal principal = handler.ValidateToken(pending.AccessToken, validationParams, out _); string tokenUsername = principal.FindFirst("preferred_username")?.Value ?? ""; if (!string.Equals(tokenUsername, req.Username, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("AuthenticateUserToken: token preferred_username did not match request Username"); await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ControllerErrorResponse("Invalid User", 401), 401, ct); return; } } catch (Exception ex) { _logger.LogWarning(ex, "AuthenticateUserToken: access token signature validation failed"); await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ControllerErrorResponse("Invalid or expired login session.", 401), 401, ct); return; } AuthenticationDTO authenticationDTO = new(); try { var base64Converter = new Base64Converter(); var privateHashEncoder = new PrivateHashEncoder(Guid.NewGuid().ToString(), new MD5Encoder()); NonceGenerator nn = new(privateHashEncoder, base64Converter); authenticationDTO.Nonce = nn.Generate(Guid.NewGuid().ToString()); authenticationDTO.Opaque = Guid.NewGuid().ToString(); authenticationDTO.Qop = "Auth"; authenticationDTO.Realm = "Private Data"; authenticationDTO.BaseUri = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; authenticationDTO.MachineIP = GetMachineIP(); authenticationDTO = await _authenticationBLL.AuthenticateUserViaKeyCloak( req.Realm, req.Username!, authenticationDTO ); authenticationDTO.SourceType = 5; // Hand the real access token back to the FE (in the response body, never a // URL) so it can attach Authorization: Bearer on subsequent calls — this is // what actually lets the multi-tenant JWT Bearer scheme validate real usage, // not just this one login handshake. authenticationDTO.KeycloakAccessToken = pending.AccessToken; SuccessDTO successDTO = new() { Body = JsonConvert.SerializeObject(authenticationDTO) }; await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ControllerSuccessResponse(successDTO.Body, CacheKeyLevel.USER_LEVEL, null!), 200, ct); } catch (Exception ex) { await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ContollerExceptionError(ex, CacheKeyLevel.NOT_REQUIRED, null!, ex.Message, 500), 500, ct); } } catch (Exception ex) { await Send.ResponseAsync( await GB5Shared.ResponseStandard.Response.ContollerExceptionError(ex, CacheKeyLevel.NOT_REQUIRED, null!, ex.Message, 500), 500, ct); } } } }