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