From ed3e7d4043e73eefac01fadf9ca6991c897410a7 Mon Sep 17 00:00:00 2001 From: OlgunR Date: Wed, 13 May 2026 13:46:45 +0200 Subject: [PATCH] Add cookie/JWT-based authentication and user context headers Introduce a new authentication mechanism using JWT tokens stored in cookies, with a custom CookieAuthHandler for API request authentication. Add AuthServiceSettings for configuration and UserHeaderHandler to propagate user context in outgoing HTTP requests. Update service registrations and configuration files to support the new authentication flow. Refactor CurrentUserService for simplicity. This enables stateless, cookie-based authentication and consistent user context across API calls. --- DbFirst.API/Program.cs | 6 + DbFirst.API/Services/AuthServiceSettings.cs | 10 ++ DbFirst.API/Services/CookieAuthHandler.cs | 112 ++++++++++++++++++ DbFirst.API/Services/CurrentUserService.cs | 11 +- DbFirst.API/appsettings.Development.json | 30 ++++- DbFirst.API/appsettings.json | 45 +++---- DbFirst.BlazorWebApp/Program.cs | 35 +++++- .../Services/UserHeaderHandler.cs | 17 +++ 8 files changed, 224 insertions(+), 42 deletions(-) create mode 100644 DbFirst.API/Services/AuthServiceSettings.cs create mode 100644 DbFirst.API/Services/CookieAuthHandler.cs create mode 100644 DbFirst.BlazorWebApp/Services/UserHeaderHandler.cs diff --git a/DbFirst.API/Program.cs b/DbFirst.API/Program.cs index 702c5a7..6458923 100644 --- a/DbFirst.API/Program.cs +++ b/DbFirst.API/Program.cs @@ -8,6 +8,7 @@ using DbFirst.Infrastructure; using DevExpress.AspNetCore; using DevExpress.DashboardAspNetCore; using DevExpress.DashboardWeb; +using Microsoft.AspNetCore.Authentication; var builder = WebApplication.CreateBuilder(args); @@ -47,6 +48,11 @@ builder.Services.AddCors(options => builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddApplication(); +builder.Services.Configure( + builder.Configuration.GetSection("AuthService")); +builder.Services.AddAuthentication("CookieAuth") + .AddScheme("CookieAuth", _ => { }); + builder.Services.AddDevExpressControls(); builder.Services.AddSignalR(); builder.Services.AddSingleton(); diff --git a/DbFirst.API/Services/AuthServiceSettings.cs b/DbFirst.API/Services/AuthServiceSettings.cs new file mode 100644 index 0000000..2c7f2e1 --- /dev/null +++ b/DbFirst.API/Services/AuthServiceSettings.cs @@ -0,0 +1,10 @@ +namespace DbFirst.API.Services +{ + public class AuthServiceSettings + { + public string BaseUrl { get; set; } = string.Empty; + public string Login { get; set; } = string.Empty; + public string Logout { get; set; } = string.Empty; + public string Check { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/DbFirst.API/Services/CookieAuthHandler.cs b/DbFirst.API/Services/CookieAuthHandler.cs new file mode 100644 index 0000000..c51cbfa --- /dev/null +++ b/DbFirst.API/Services/CookieAuthHandler.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text; +using System.Text.Json; + +namespace DbFirst.API.Services; + +/// +/// Authentifiziert eingehende API-Requests anhand des JWT-Tokens im Cookie. +/// Das Token wird lokal dekodiert – ohne Rückruf zum Auth-Service – da es +/// self-contained ist (Claim "unique_name" enthält den Benutzernamen). +/// +public class CookieAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var log = logger.CreateLogger(); + + // 1. Cookie-Header lesen + var cookieHeader = Request.Headers.Cookie.ToString(); + if (string.IsNullOrEmpty(cookieHeader)) + { + log.LogDebug("CookieAuthHandler: Kein Cookie-Header vorhanden."); + return Task.FromResult(AuthenticateResult.Fail("Kein Cookie vorhanden.")); + } + + // 2. JWT aus dem "AuthToken"-Cookie extrahieren und Benutzernamen dekodieren + var userName = TryExtractUserNameFromCookieJwt(cookieHeader); + + // 3. Fallback: X-Authenticated-User-Header + if (string.IsNullOrEmpty(userName)) + userName = Request.Headers["X-Authenticated-User"].ToString(); + + if (string.IsNullOrEmpty(userName)) + { + log.LogDebug("CookieAuthHandler: Kein Benutzername aus Token oder Header ermittelbar."); + return Task.FromResult(AuthenticateResult.Fail("Kein Benutzername ermittelbar.")); + } + + log.LogDebug("CookieAuthHandler: Authentifizierung erfolgreich für '{User}'.", userName); + + var claims = new[] { new Claim(ClaimTypes.Name, userName) }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + /// + /// Liest den "AuthToken"-Wert aus dem Cookie-Header und extrahiert + /// den "unique_name"-Claim aus dem JWT-Payload (Base64Url-dekodiert). + /// Keine Signaturprüfung – das Token wurde bereits beim Login/Restore + /// durch den Auth-Service validiert. + /// + private static string? TryExtractUserNameFromCookieJwt(string cookieHeader) + { + foreach (var segment in cookieHeader.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var eqIdx = segment.IndexOf('='); + if (eqIdx < 0) continue; + + var name = segment[..eqIdx].Trim(); + if (!name.Equals("AuthToken", StringComparison.OrdinalIgnoreCase)) + continue; + + var token = segment[(eqIdx + 1)..].Trim(); + return DecodeJwtClaim(token, "unique_name"); + } + + return null; + } + + /// + /// Dekodiert den Payload-Teil eines JWT (Base64Url) und gibt den Wert + /// des angegebenen Claims zurück. + /// + private static string? DecodeJwtClaim(string jwt, string claimName) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length != 3) return null; + + // Base64Url → Base64 → bytes → UTF-8 string + var base64 = parts[1].Replace('-', '+').Replace('_', '/'); + base64 = (base64.Length % 4) switch + { + 2 => base64 + "==", + 3 => base64 + "=", + _ => base64 + }; + + var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + + using var doc = JsonDocument.Parse(payloadJson); + if (doc.RootElement.TryGetProperty(claimName, out var prop)) + return prop.GetString(); + } + catch + { + // Fehlerhafte Token stillschweigend ignorieren + } + + return null; + } +} diff --git a/DbFirst.API/Services/CurrentUserService.cs b/DbFirst.API/Services/CurrentUserService.cs index 6846003..f63cada 100644 --- a/DbFirst.API/Services/CurrentUserService.cs +++ b/DbFirst.API/Services/CurrentUserService.cs @@ -1,10 +1,9 @@ using DbFirst.Application.Abstractions; -namespace DbFirst.API.Services +namespace DbFirst.API.Services; + +public class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService { - public class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService - { - public string UserName => - httpContextAccessor.HttpContext?.User.Identity?.Name ?? "unknown"; - } + public string UserName => + httpContextAccessor.HttpContext?.User.Identity?.Name ?? "unknown"; } diff --git a/DbFirst.API/appsettings.Development.json b/DbFirst.API/appsettings.Development.json index 0c208ae..7f91953 100644 --- a/DbFirst.API/appsettings.Development.json +++ b/DbFirst.API/appsettings.Development.json @@ -2,7 +2,31 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "DbFirst.API.Services.CookieAuthHandler": "Debug" } - } -} + }, + "AuthService": { + "BaseUrl": "http://172.24.12.39:9090/", + "Login": "api/Auth/db-first/login", + "Logout": "api/Auth/logout", + "Check": "api/Auth/check" + }, + "ConnectionStrings": { + "DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;", + "MassDataConnection": "Server=SDD-VMP04-SQL19\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;" + }, + "Cors": { + "AllowedOrigins": [ + "https://localhost:7276", + "http://localhost:5101" + ] + }, + "Dashboard": { + "BaseUrl": "https://localhost:7204" + }, + "BrowserLink": { + "Enabled": false + }, + "DetailedErrors": true +} \ No newline at end of file diff --git a/DbFirst.API/appsettings.json b/DbFirst.API/appsettings.json index 02a6d09..2b98c6b 100644 --- a/DbFirst.API/appsettings.json +++ b/DbFirst.API/appsettings.json @@ -1,34 +1,25 @@ { - "ConnectionStrings": { - "DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;", - "MassDataConnection": "Server=SDD-VMP04-SQL19\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;" - }, - "Dashboard": { - "BaseUrl": "https://localhost:7204" - }, - "Cors": { - "AllowedOrigins": [ - "https://localhost:7276", - "http://localhost:5101" - ] - }, - "TableConfigurations": { - "VwmyCatalog": { - "ViewName": "VWMY_CATALOG", - "GuidColumnName": "GUID", - "CatTitleColumnName": "CAT_TITLE", - "CatStringColumnName": "CAT_STRING", - "AddedWhoColumnName": "ADDED_WHO", - "AddedWhenColumnName": "ADDED_WHEN", - "ChangedWhoColumnName": "CHANGED_WHO", - "ChangedWhenColumnName": "CHANGED_WHEN" - } - }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" -} + "AllowedHosts": "*", + "AuthService": { + "BaseUrl": null, + "Login": "api/Auth/db-first/login", + "Logout": "api/Auth/logout", + "Check": "api/Auth/check" + }, + "ConnectionStrings": { + "DefaultConnection": null, + "MassDataConnection": null + }, + "Cors": { + "AllowedOrigins": [] + }, + "Dashboard": { + "BaseUrl": null + } +} \ No newline at end of file diff --git a/DbFirst.BlazorWebApp/Program.cs b/DbFirst.BlazorWebApp/Program.cs index b3e959d..442426d 100644 --- a/DbFirst.BlazorWebApp/Program.cs +++ b/DbFirst.BlazorWebApp/Program.cs @@ -21,7 +21,18 @@ var appSettings = builder.Configuration.Get() ?? new AppSettings(); // Alle API-Clients teilen sich denselben scoped CookieContainer (pro Blazor-Circuit), // damit das Auth-Cookie nach dem Login automatisch an alle Folgeanfragen angehängt wird. -static HttpClient CreateHttpClientWithCookies(CookieContainer cookieContainer, string? baseUrl) +// Der UserHeaderHandler ergänzt automatisch den X-Authenticated-User-Header. +static HttpClient CreateApiHttpClient(CookieContainer cookieContainer, AuthService authService, string? baseUrl) +{ + var inner = new HttpClientHandler { UseCookies = false }; + var handler = new UserHeaderHandler(authService) { InnerHandler = inner }; + var client = new HttpClient(handler); + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl); + return client; +} + +static HttpClient CreateAuthHttpClient(CookieContainer cookieContainer, string? baseUrl) { var handler = new HttpClientHandler { CookieContainer = cookieContainer, UseCookies = true }; var client = new HttpClient(handler); @@ -36,7 +47,7 @@ builder.Services.AddScoped(sp => var authBaseUrl = !string.IsNullOrWhiteSpace(appSettings.AuthService.BaseUrl) ? appSettings.AuthService.BaseUrl : appSettings.BaseUrl; - var client = CreateHttpClientWithCookies(cc, authBaseUrl); + var client = CreateAuthHttpClient(cc, authBaseUrl); return new AuthApiClient(client, sp.GetRequiredService(), cc); }); @@ -45,16 +56,28 @@ var apiDefaultUrl = !string.IsNullOrWhiteSpace(appSettings.ApiDefaultUrl) : appSettings.BaseUrl; builder.Services.AddScoped(sp => - new CatalogApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), apiDefaultUrl))); + new CatalogApiClient(CreateApiHttpClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + apiDefaultUrl))); builder.Services.AddScoped(sp => - new DashboardApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), apiDefaultUrl))); + new DashboardApiClient(CreateApiHttpClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + apiDefaultUrl))); builder.Services.AddScoped(sp => - new MassDataApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), apiDefaultUrl))); + new MassDataApiClient(CreateApiHttpClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + apiDefaultUrl))); builder.Services.AddScoped(sp => - new LayoutApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), apiDefaultUrl))); + new LayoutApiClient(CreateApiHttpClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + apiDefaultUrl))); var app = builder.Build(); diff --git a/DbFirst.BlazorWebApp/Services/UserHeaderHandler.cs b/DbFirst.BlazorWebApp/Services/UserHeaderHandler.cs new file mode 100644 index 0000000..fb13409 --- /dev/null +++ b/DbFirst.BlazorWebApp/Services/UserHeaderHandler.cs @@ -0,0 +1,17 @@ +namespace DbFirst.BlazorWebApp.Services; + +public class UserHeaderHandler(AuthService authService) : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (authService.IsAuthenticated) + { + request.Headers.TryAddWithoutValidation("X-Authenticated-User", authService.UserName); + if (!string.IsNullOrEmpty(authService.RawCookieHeader)) + request.Headers.TryAddWithoutValidation("Cookie", authService.RawCookieHeader); + } + + return base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file