Compare commits

...

4 Commits

Author SHA1 Message Date
OlgunR
ed3e7d4043 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.
2026-05-13 13:46:45 +02:00
OlgunR
de5d1b666c Add current user service and use for catalog audit fields
Introduce ICurrentUserService and its implementation to access the current user's username. Inject this service into CreateCatalogHandler and UpdateCatalogHandler to set AddedWho and ChangedWho fields dynamically. Register the service and IHttpContextAccessor in DI, and enable authentication middleware. Update project and using statements accordingly.
2026-05-13 09:05:04 +02:00
OlgunR
91ee044b73 HAKAN'S REQUIREMENT - Refactor API base URL configuration structure
Replaced ApiBaseUrl and DataApiBaseUrl with BaseUrl, ApiDefaultUrl, and service-specific configuration sections (AuthService, UserManagerService, DbFirstService). Updated AppSettings and all code references to use the new structure. Revised logic for HTTP client base URL selection to prefer service-specific settings. Updated appsettings files and added an example configuration for clarity and maintainability.
2026-05-12 16:34:26 +02:00
OlgunR
1ad267e409 Add authentication support with login/logout UI
- Introduced AuthService, IAuthApiClient, and AuthApiClient for managing authentication state and API calls (login, logout, session restore).
- Added Login.razor and LoginLayout.razor for the login page, including styling and logic.
- MainLayout.razor now checks authentication on load, restores sessions from sessionStorage, and redirects to /login if unauthenticated. Displays username and logout button when logged in.
- Implemented JS interop (authStorage) for persisting authentication info in sessionStorage.
- Registered AuthService, CookieContainer, and API clients in Program.cs to share cookies and support authentication.
- Updated AppSettings and appsettings files to support separate ApiBaseUrl and DataApiBaseUrl.
- Minor CSS improvements for username display in the top bar.
2026-05-12 16:32:46 +02:00
27 changed files with 779 additions and 59 deletions

View File

@@ -28,7 +28,7 @@ public static class DashboardConfiguratorFactory
}
var dashboardBaseUrl = configuration["Dashboard:BaseUrl"]
?? configuration["ApiBaseUrl"]
?? configuration["BaseUrl"]
?? configuration["ASPNETCORE_URLS"]?.Split(';', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
?? "https://localhost:7204";

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>

View File

@@ -1,11 +1,14 @@
using DbFirst.API.Dashboards;
using DbFirst.API.Hubs;
using DbFirst.API.Middleware;
using DbFirst.API.Services;
using DbFirst.Application;
using DbFirst.Application.Abstractions;
using DbFirst.Infrastructure;
using DevExpress.AspNetCore;
using DevExpress.DashboardAspNetCore;
using DevExpress.DashboardWeb;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
@@ -45,12 +48,20 @@ builder.Services.AddCors(options =>
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
builder.Services.Configure<AuthServiceSettings>(
builder.Configuration.GetSection("AuthService"));
builder.Services.AddAuthentication("CookieAuth")
.AddScheme<AuthenticationSchemeOptions, CookieAuthHandler>("CookieAuth", _ => { });
builder.Services.AddDevExpressControls();
builder.Services.AddSignalR();
builder.Services.AddSingleton<IDashboardChangeNotifier, DashboardChangeNotifier>();
builder.Services.AddScoped<DashboardConfigurator>(sp =>
DashboardConfiguratorFactory.Create(sp, builder.Configuration, builder.Environment));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
@@ -65,6 +76,7 @@ app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseDevExpressControls();
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapDashboardRoute("api/dashboard", "DefaultDashboard");

View File

@@ -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;
}
}

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
public class CookieAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
System.Text.Encodings.Web.UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var log = logger.CreateLogger<CookieAuthHandler>();
// 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));
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Dekodiert den Payload-Teil eines JWT (Base64Url) und gibt den Wert
/// des angegebenen Claims zurück.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,9 @@
using DbFirst.Application.Abstractions;
namespace DbFirst.API.Services;
public class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
public string UserName =>
httpContextAccessor.HttpContext?.User.Identity?.Name ?? "unknown";
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,7 @@
namespace DbFirst.Application.Abstractions
{
public interface ICurrentUserService
{
string UserName { get; }
}
}

View File

@@ -1,4 +1,5 @@
using AutoMapper;
using DbFirst.Application.Abstractions;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs;
using DbFirst.Domain.Entities;
@@ -10,11 +11,13 @@ public class CreateCatalogHandler : IRequestHandler<CreateCatalogCommand, Catalo
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
private readonly ICurrentUserService _currentUserService;
public CreateCatalogHandler(ICatalogRepository repository, IMapper mapper)
public CreateCatalogHandler(ICatalogRepository repository, IMapper mapper, ICurrentUserService currentUserService)
{
_repository = repository;
_mapper = mapper;
_currentUserService = currentUserService;
}
public async Task<CatalogReadDto?> Handle(CreateCatalogCommand request, CancellationToken cancellationToken)
@@ -26,9 +29,9 @@ public class CreateCatalogHandler : IRequestHandler<CreateCatalogCommand, Catalo
}
var entity = _mapper.Map<VwmyCatalog>(request.Dto);
entity.AddedWho = "system";
entity.AddedWho = _currentUserService.UserName;
entity.AddedWhen = DateTime.UtcNow;
entity.ChangedWho = "system";
entity.ChangedWho = _currentUserService.UserName;
entity.ChangedWhen = DateTime.UtcNow;
var created = await _repository.InsertAsync(entity, cancellationToken);

View File

@@ -1,4 +1,5 @@
using AutoMapper;
using DbFirst.Application.Abstractions;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs;
using DbFirst.Domain;
@@ -11,11 +12,13 @@ public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, Catalo
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
private readonly ICurrentUserService _currentUserService;
public UpdateCatalogHandler(ICatalogRepository repository, IMapper mapper)
public UpdateCatalogHandler(ICatalogRepository repository, IMapper mapper, ICurrentUserService currentUserService)
{
_repository = repository;
_mapper = mapper;
_currentUserService = currentUserService;
}
public async Task<CatalogReadDto?> Handle(UpdateCatalogCommand request, CancellationToken cancellationToken)
@@ -36,7 +39,7 @@ public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, Catalo
entity.Guid = request.Id;
entity.AddedWho = existing.AddedWho;
entity.AddedWhen = existing.AddedWhen;
entity.ChangedWho = "system";
entity.ChangedWho = _currentUserService.UserName;
entity.ChangedWhen = DateTime.UtcNow;
var updated = await _repository.UpdateAsync(request.Id, entity, request.Dto.UpdateProcedure, cancellationToken);

View File

@@ -2,5 +2,15 @@
public class AppSettings
{
public string ApiBaseUrl { get; set; } = string.Empty;
public string BaseUrl { get; set; } = string.Empty;
public string ApiDefaultUrl { get; set; } = string.Empty;
public AuthServiceSettings AuthService { get; set; } = new();
}
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;
}

View File

@@ -31,6 +31,18 @@
else
document.documentElement.classList.remove('dx-dark');
};
window.authStorage = {
get: function (key) { return sessionStorage.getItem(key); },
set: function (username, cookie) {
sessionStorage.setItem('auth_user', username);
sessionStorage.setItem('auth_cookie', cookie);
},
clear: function () {
sessionStorage.removeItem('auth_user');
sessionStorage.removeItem('auth_cookie');
}
};
</script>
<HeadOutlet />
</head>

View File

@@ -0,0 +1,10 @@
@inherits LayoutComponentBase
@inject ThemeState ThemeState
<div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light") @(ThemeState.IsNativeDarkTheme ? "native-dark" : "")">
<main>
<article class="content">
@Body
</article>
</main>
</div>

View File

@@ -2,6 +2,9 @@
@implements IDisposable
@inject ThemeState ThemeState
@inject IJSRuntime JS
@inject AuthService AuthService
@inject IAuthApiClient AuthApiClient
@inject NavigationManager Navigation
<div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light") @(ThemeState.IsNativeDarkTheme ? "native-dark" : "")">
<div class="sidebar">
@@ -18,11 +21,29 @@
<DxButton Text="@(ThemeState.IsDarkMode ? "Dark Mode aus" : "Dark Mode an")"
Click="ToggleTheme" />
</span>
@if (AuthService.IsAuthenticated)
{
<span class="ms-auto d-flex align-items-center gap-2">
<span class="top-row-username">@AuthService.UserName</span>
<DxButton Text="Abmelden"
Click="LogoutAsync"
RenderStyle="ButtonRenderStyle.Secondary" />
</span>
}
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@if (_checkingAuth)
{
<div class="loading-container">
<span>Wird geladen…</span>
</div>
}
else
{
@Body
}
</article>
</main>
</div>
@@ -35,10 +56,12 @@
@code {
private bool _isInteractive;
private bool _checkingAuth = true;
protected override void OnInitialized()
{
ThemeState.OnChange += OnThemeChanged;
AuthService.OnChange += OnAuthChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -46,10 +69,64 @@
if (firstRender)
{
_isInteractive = true;
if (!AuthService.IsAuthenticated)
{
try
{
var username = await JS.InvokeAsync<string?>("authStorage.get", "auth_user");
var cookie = await JS.InvokeAsync<string?>("authStorage.get", "auth_cookie");
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(cookie))
{
var restored = await AuthApiClient.RestoreAsync(username, cookie);
if (!restored)
{
await JS.InvokeVoidAsync("authStorage.clear");
Navigation.NavigateTo("/login", replace: true);
return;
}
}
else
{
Navigation.NavigateTo("/login", replace: true);
return;
}
}
catch
{
Navigation.NavigateTo("/login", replace: true);
return;
}
}
_checkingAuth = false;
StateHasChanged();
}
await ApplyDxDarkOverrideAsync();
}
private async Task LogoutAsync()
{
try
{
await AuthApiClient.LogoutAsync();
await JS.InvokeVoidAsync("authStorage.clear");
}
catch
{
// Fehler beim Abmelden ignorieren, trotzdem weiterleiten
}
Navigation.NavigateTo("/login", replace: true);
}
private void OnAuthChanged()
{
InvokeAsync(StateHasChanged);
}
private void OnThemeChanged()
{
InvokeAsync(async () =>
@@ -82,5 +159,6 @@
public void Dispose()
{
ThemeState.OnChange -= OnThemeChanged;
AuthService.OnChange -= OnAuthChanged;
}
}

View File

@@ -45,8 +45,8 @@
private string SelectedDashboardId { get; set; } = string.Empty;
private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}";
private string DashboardEndpoint => $"{AppSettingsOptions.Value.ApiBaseUrl.TrimEnd('/')}/api/dashboard";
private string HubEndpoint => $"{AppSettingsOptions.Value.ApiBaseUrl.TrimEnd('/')}/hubs/dashboards";
private string DashboardEndpoint => $"{AppSettingsOptions.Value.BaseUrl.TrimEnd('/')}/api/dashboard";
private string HubEndpoint => $"{AppSettingsOptions.Value.BaseUrl.TrimEnd('/')}/hubs/dashboards";
protected override async Task OnInitializedAsync()
{

View File

@@ -0,0 +1,106 @@
@page "/login"
@layout DbFirst.BlazorWebApp.Components.Layout.LoginLayout
@rendermode InteractiveServer
@inject IAuthApiClient AuthApiClient
@inject AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Anmelden DbFirst</PageTitle>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<span class="login-brand">DbFirst</span>
<p class="login-subtitle">Bitte melden Sie sich an</p>
</div>
<form @onsubmit="HandleLoginAsync" @onsubmit:preventDefault>
<div class="login-field">
<label class="login-label" for="username">Benutzername</label>
<input id="username"
type="text"
class="login-text-input"
placeholder="Benutzername eingeben"
autocomplete="username"
@bind="_username"
@bind:event="oninput" />
</div>
<div class="login-field">
<label class="login-label" for="password">Passwort</label>
<input id="password"
type="password"
class="login-text-input"
placeholder="Passwort eingeben"
autocomplete="current-password"
@bind="_password"
@bind:event="oninput" />
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger mt-2 mb-1" role="alert">
@_errorMessage
</div>
}
<div class="login-actions">
<button type="submit"
class="login-submit-btn"
disabled="@_isLoading">
@(_isLoading ? "Wird angemeldet…" : "Anmelden")
</button>
</div>
</form>
</div>
</div>
@code {
private string _username = string.Empty;
private string _password = string.Empty;
private string _errorMessage = string.Empty;
private bool _isLoading;
protected override void OnInitialized()
{
if (AuthService.IsAuthenticated)
Navigation.NavigateTo("/");
}
private async Task HandleLoginAsync()
{
if (_isLoading) return;
_errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(_username) || string.IsNullOrWhiteSpace(_password))
{
_errorMessage = "Bitte Benutzername und Passwort eingeben.";
return;
}
_isLoading = true;
try
{
var success = await AuthApiClient.LoginAsync(_username, _password);
if (success)
{
await JS.InvokeVoidAsync("authStorage.set", AuthService.UserName, AuthService.RawCookieHeader);
Navigation.NavigateTo("/");
}
else
{
_errorMessage = "Anmeldung fehlgeschlagen. Bitte Benutzerdaten prüfen.";
}
}
catch
{
_errorMessage = "Verbindungsfehler. Bitte später erneut versuchen.";
}
finally
{
_isLoading = false;
}
}
}

View File

@@ -0,0 +1,111 @@
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 2rem);
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
border-radius: 8px;
border: 1px solid var(--band-editor-border, #dee2e6);
background-color: var(--band-editor-bg, #fff);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.login-header {
text-align: center;
margin-bottom: 1.75rem;
}
.login-brand {
font-size: 1.8rem;
font-weight: 700;
color: #1b6ec2;
letter-spacing: 0.02em;
}
.login-subtitle {
margin-top: 0.35rem;
margin-bottom: 0;
font-size: 0.9rem;
color: #6c757d;
}
.login-field {
margin-bottom: 1rem;
}
.login-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.3rem;
}
.login-actions {
margin-top: 1.25rem;
}
/* Gemeinsamer Stil für Text- und Passwort-Eingabefelder */
.login-text-input {
display: block;
width: 100%;
height: 2.25rem;
padding: 0.25rem 0.65rem;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: inherit;
background-color: transparent;
border: 1px solid #ced4da;
border-radius: 4px;
box-sizing: border-box;
outline: none;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.login-text-input:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* Submit-Button */
.login-submit-btn {
display: block;
width: 100%;
padding: 0.45rem 1rem;
font-size: 1rem;
font-family: inherit;
font-weight: 500;
color: #fff;
background-color: #1b6ec2;
border: 1px solid #1861ac;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
}
.login-submit-btn:hover:not(:disabled) {
background-color: #1558a0;
border-color: #1456a0;
}
.login-submit-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* Dark-Mode */
.app-dark .login-text-input {
border-color: #555;
color: #e8e8e8;
background-color: #2d2d2d;
}
.app-dark .login-text-input:focus {
border-color: #6cb6ff;
box-shadow: 0 0 0 0.2rem rgba(108, 182, 255, 0.25);
}

View File

@@ -2,6 +2,7 @@ using DbFirst.BlazorWebApp;
using DbFirst.BlazorWebApp.Components;
using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
@@ -12,19 +13,71 @@ builder.Services.AddRazorComponents()
builder.Services.AddDevExpressBlazor(options => options.BootstrapVersion = BootstrapVersion.v5);
builder.Services.AddScoped<ThemeState>();
builder.Services.AddScoped<BandLayoutService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CookieContainer>();
builder.Services.Configure<AppSettings>(builder.Configuration);
var appSettings = builder.Configuration.Get<AppSettings>() ?? new AppSettings();
void ConfigureClient(HttpClient client)
// 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.
// Der UserHeaderHandler ergänzt automatisch den X-Authenticated-User-Header.
static HttpClient CreateApiHttpClient(CookieContainer cookieContainer, AuthService authService, string? baseUrl)
{
if (!string.IsNullOrWhiteSpace(appSettings.ApiBaseUrl))
client.BaseAddress = new Uri(appSettings.ApiBaseUrl);
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;
}
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(ConfigureClient);
builder.Services.AddHttpClient<IDashboardApiClient, DashboardApiClient>(ConfigureClient);
builder.Services.AddHttpClient<IMassDataApiClient, MassDataApiClient>(ConfigureClient);
builder.Services.AddHttpClient<ILayoutApiClient, LayoutApiClient>(ConfigureClient);
static HttpClient CreateAuthHttpClient(CookieContainer cookieContainer, string? baseUrl)
{
var handler = new HttpClientHandler { CookieContainer = cookieContainer, UseCookies = true };
var client = new HttpClient(handler);
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl);
return client;
}
builder.Services.AddScoped<IAuthApiClient>(sp =>
{
var cc = sp.GetRequiredService<CookieContainer>();
var authBaseUrl = !string.IsNullOrWhiteSpace(appSettings.AuthService.BaseUrl)
? appSettings.AuthService.BaseUrl
: appSettings.BaseUrl;
var client = CreateAuthHttpClient(cc, authBaseUrl);
return new AuthApiClient(client, sp.GetRequiredService<AuthService>(), cc);
});
var apiDefaultUrl = !string.IsNullOrWhiteSpace(appSettings.ApiDefaultUrl)
? appSettings.ApiDefaultUrl
: appSettings.BaseUrl;
builder.Services.AddScoped<ICatalogApiClient>(sp =>
new CatalogApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
builder.Services.AddScoped<IDashboardApiClient>(sp =>
new DashboardApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
builder.Services.AddScoped<IMassDataApiClient>(sp =>
new MassDataApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
builder.Services.AddScoped<ILayoutApiClient>(sp =>
new LayoutApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
var app = builder.Build();

View File

@@ -0,0 +1,64 @@
using System.Net;
namespace DbFirst.BlazorWebApp.Services;
public class AuthApiClient(HttpClient httpClient, AuthService authService, CookieContainer cookieContainer) : IAuthApiClient
{
private const string LoginEndpoint = "api/Auth/db-first/login";
private const string LogoutEndpoint = "api/Auth/logout";
private const string CheckEndpoint = "api/Auth/check";
public async Task<bool> LoginAsync(string username, string password, CancellationToken ct = default)
{
var content = new MultipartFormDataContent();
content.Add(new StringContent(username), "Username");
content.Add(new StringContent(password), "Password");
content.Add(new StringContent(string.Empty), "UserId");
var response = await httpClient.PostAsync(LoginEndpoint, content, ct);
if (!response.IsSuccessStatusCode)
return false;
var rawCookie = ExtractCookies();
authService.SetAuthenticated(username, rawCookie);
return true;
}
public async Task LogoutAsync(CancellationToken ct = default)
{
await httpClient.PostAsync(LogoutEndpoint, null, ct);
authService.SetUnauthenticated();
}
public async Task<bool> RestoreAsync(string username, string rawCookieHeader, CancellationToken ct = default)
{
RestoreCookies(rawCookieHeader);
var response = await httpClient.GetAsync(CheckEndpoint, ct);
if (response.IsSuccessStatusCode)
{
authService.SetAuthenticated(username, rawCookieHeader);
return true;
}
authService.SetUnauthenticated();
return false;
}
private string ExtractCookies()
{
if (httpClient.BaseAddress is null) return string.Empty;
var cookies = cookieContainer.GetCookies(httpClient.BaseAddress);
return string.Join("; ", cookies.Cast<Cookie>().Select(c => $"{c.Name}={c.Value}"));
}
private void RestoreCookies(string rawCookieHeader)
{
if (httpClient.BaseAddress is null) return;
foreach (var part in rawCookieHeader.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var eqIdx = part.IndexOf('=');
if (eqIdx > 0)
cookieContainer.Add(httpClient.BaseAddress, new Cookie(part[..eqIdx].Trim(), part[(eqIdx + 1)..].Trim()));
}
}
}

View File

@@ -0,0 +1,26 @@
namespace DbFirst.BlazorWebApp.Services;
public class AuthService
{
public bool IsAuthenticated { get; private set; }
public string UserName { get; private set; } = string.Empty;
public string? RawCookieHeader { get; private set; }
public event Action? OnChange;
public void SetAuthenticated(string userName, string rawCookieHeader)
{
IsAuthenticated = true;
UserName = userName;
RawCookieHeader = rawCookieHeader;
OnChange?.Invoke();
}
public void SetUnauthenticated()
{
IsAuthenticated = false;
UserName = string.Empty;
RawCookieHeader = null;
OnChange?.Invoke();
}
}

View File

@@ -0,0 +1,8 @@
namespace DbFirst.BlazorWebApp.Services;
public interface IAuthApiClient
{
Task<bool> LoginAsync(string username, string password, CancellationToken ct = default);
Task LogoutAsync(CancellationToken ct = default);
Task<bool> RestoreAsync(string username, string rawCookieHeader, CancellationToken ct = default);
}

View File

@@ -0,0 +1,17 @@
namespace DbFirst.BlazorWebApp.Services;
public class UserHeaderHandler(AuthService authService) : DelegatingHandler
{
protected override Task<HttpResponseMessage> 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);
}
}

View File

@@ -5,11 +5,25 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ApiBaseUrl": "https://localhost:7204/",
"ApiDefaultUrl": "https://localhost:7204/",
"AuthService": {
"BaseUrl": "http://172.24.12.39:9090/",
"Login": "/api/Auth/db-first/login",
"Logout": "auth/logout",
"Check": "auth/check"
},
"UserManagerService": {
"BaseUrl": null,
"GetUserInfo": "user/info",
"UpdateUserInfo": "user/update"
},
"DbFirstService": {
"BaseUrl": "https://localhost:7204/",
"GetData": "dbfirst/data",
"UpdateData": "dbfirst/update"
},
"BrowserLink": {
"Enabled": false
},
"DetailedErrors": true
}

View File

@@ -0,0 +1,19 @@
{
"ApiDefaultUrl": "https://localhost:7204/",
"AuthService": {
"BaseUrl": "http://172.24.12.39:9090/",
"Login": "/api/Auth/db-first/login",
"Logout": "auth/logout",
"Check": "auth/check"
},
"UserManagerService": {
"BaseUrl": null,
"GetUserInfo": "user/info",
"UpdateUserInfo": "user/update"
},
"DbFirstService": {
"BaseUrl": "https://localhost:7204/",
"GetData": "dbfirst/data",
"UpdateData": "dbfirst/update"
}
}

View File

@@ -5,6 +5,21 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ApiBaseUrl": "https://localhost:7204/",
"AllowedHosts": "*"
"ApiDefaultUrl": "https://localhost:7204/",
"AuthService": {
"BaseUrl": null,
"Login": "auth/login",
"Logout": "auth/logout",
"Check": "auth/check"
},
"UserManagerService": {
"BaseUrl": null,
"GetUserInfo": "user/info",
"UpdateUserInfo": "user/update"
},
"DbFirstService": {
"BaseUrl": null,
"GetData": "dbfirst/data",
"UpdateData": "dbfirst/update"
}
}

View File

@@ -295,3 +295,9 @@ html.dx-dark .dxbl-grid > .dxbl-grid-top-panel {
.top-row .btn-gap {
margin-left: 8px;
}
.top-row-username {
font-size: 0.85rem;
color: #6c757d;
white-space: nowrap;
}