From ad9b6bfdaff1bac4bca1294d9da4f57b4a4c5165 Mon Sep 17 00:00:00 2001 From: triggermeelmo Date: Wed, 21 Jan 2026 10:05:03 +0100 Subject: [PATCH] OIDC Integration --- docker-compose.yaml | 11 ++ .../Configuration/OidcSettings.cs | 53 +++++ .../Controllers/AuthController.cs | 135 ++++++++++++- ...121084748_AddOidcSubjectToUser.Designer.cs | 181 ++++++++++++++++++ .../20260121084748_AddOidcSubjectToUser.cs | 40 ++++ .../WatcherDbContextModelSnapshot.cs | 4 + watcher-monitoring/Models/User.cs | 5 + watcher-monitoring/Program.cs | 39 +++- watcher-monitoring/Views/Auth/Login.cshtml | 15 ++ watcher-monitoring/watcher-monitoring.csproj | 1 + 10 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 watcher-monitoring/Configuration/OidcSettings.cs create mode 100644 watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.Designer.cs create mode 100644 watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.cs diff --git a/docker-compose.yaml b/docker-compose.yaml index 6b9c222..c5710b5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -44,6 +44,17 @@ services: # Aktualisierungsrate Frontend - FRONTEND_REFRESH_INTERVAL_SECONDS=30 + # OIDC-Authentifizierung (Optional) + # - OIDC_ENABLED=true + # - OIDC_AUTHORITY=https://auth.example.com/realms/myrealm + # - OIDC_CLIENT_ID=watcher-client + # - OIDC_CLIENT_SECRET=your-client-secret + # - OIDC_SCOPES=openid profile email + # - OIDC_CALLBACK_PATH=/signin-oidc + # - OIDC_CLAIM_USERNAME=preferred_username + # - OIDC_CLAIM_EMAIL=email + # - OIDC_AUTO_PROVISION_USERS=true + # Ports ports: - "5000:5000" diff --git a/watcher-monitoring/Configuration/OidcSettings.cs b/watcher-monitoring/Configuration/OidcSettings.cs new file mode 100644 index 0000000..6b3e820 --- /dev/null +++ b/watcher-monitoring/Configuration/OidcSettings.cs @@ -0,0 +1,53 @@ +namespace watcher_monitoring.Configuration; + +public class OidcSettings +{ + public bool Enabled { get; set; } = false; + + public string Authority { get; set; } = string.Empty; + + public string ClientId { get; set; } = string.Empty; + + public string ClientSecret { get; set; } = string.Empty; + + public string Scopes { get; set; } = "openid profile email"; + + public string CallbackPath { get; set; } = "/signin-oidc"; + + public string ClaimUsername { get; set; } = "preferred_username"; + + public string ClaimEmail { get; set; } = "email"; + + public bool AutoProvisionUsers { get; set; } = true; + + public bool IsValid => Enabled && + !string.IsNullOrWhiteSpace(Authority) && + !string.IsNullOrWhiteSpace(ClientId) && + !string.IsNullOrWhiteSpace(ClientSecret); + + public string[] GetScopes() => Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + public static OidcSettings FromEnvironment() + { + return new OidcSettings + { + Enabled = GetBoolEnv("OIDC_ENABLED", false), + Authority = Environment.GetEnvironmentVariable("OIDC_AUTHORITY") ?? string.Empty, + ClientId = Environment.GetEnvironmentVariable("OIDC_CLIENT_ID") ?? string.Empty, + ClientSecret = Environment.GetEnvironmentVariable("OIDC_CLIENT_SECRET") ?? string.Empty, + Scopes = Environment.GetEnvironmentVariable("OIDC_SCOPES") ?? "openid profile email", + CallbackPath = Environment.GetEnvironmentVariable("OIDC_CALLBACK_PATH") ?? "/signin-oidc", + ClaimUsername = Environment.GetEnvironmentVariable("OIDC_CLAIM_USERNAME") ?? "preferred_username", + ClaimEmail = Environment.GetEnvironmentVariable("OIDC_CLAIM_EMAIL") ?? "email", + AutoProvisionUsers = GetBoolEnv("OIDC_AUTO_PROVISION_USERS", true) + }; + } + + private static bool GetBoolEnv(string key, bool defaultValue) + { + var value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) return defaultValue; + return value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("1", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/watcher-monitoring/Controllers/AuthController.cs b/watcher-monitoring/Controllers/AuthController.cs index f44a7fb..2c624e3 100644 --- a/watcher-monitoring/Controllers/AuthController.cs +++ b/watcher-monitoring/Controllers/AuthController.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Security.Claims; +using watcher_monitoring.Configuration; using watcher_monitoring.Data; using watcher_monitoring.Models; @@ -13,11 +15,13 @@ public class AuthController : Controller { private readonly WatcherDbContext _context; private readonly ILogger _logger; + private readonly OidcSettings _oidcSettings; - public AuthController(WatcherDbContext context, ILogger logger) + public AuthController(WatcherDbContext context, ILogger logger, OidcSettings oidcSettings) { _context = context; _logger = logger; + _oidcSettings = oidcSettings; } [AllowAnonymous] @@ -31,9 +35,138 @@ public class AuthController : Controller } ViewData["ReturnUrl"] = returnUrl; + ViewData["OidcEnabled"] = _oidcSettings.IsValid; return View(); } + [AllowAnonymous] + [HttpGet] + public IActionResult OidcLogin(string? returnUrl = null) + { + if (!_oidcSettings.IsValid) + { + _logger.LogWarning("OIDC-Login versucht, aber OIDC ist nicht konfiguriert"); + return RedirectToAction("Login"); + } + + var properties = new AuthenticationProperties + { + RedirectUri = Url.Action("OidcCallback", "Auth", new { returnUrl }), + Items = { { "returnUrl", returnUrl ?? "/" } } + }; + + return Challenge(properties, OpenIdConnectDefaults.AuthenticationScheme); + } + + [AllowAnonymous] + [HttpGet] + public async Task OidcCallback(string? returnUrl = null) + { + var authenticateResult = await HttpContext.AuthenticateAsync(OpenIdConnectDefaults.AuthenticationScheme); + + if (!authenticateResult.Succeeded) + { + _logger.LogWarning("OIDC-Authentifizierung fehlgeschlagen: {Failure}", authenticateResult.Failure?.Message); + TempData["Error"] = "OIDC-Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."; + return RedirectToAction("Login"); + } + + var oidcClaims = authenticateResult.Principal?.Claims; + var oidcSubject = oidcClaims?.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value + ?? oidcClaims?.FirstOrDefault(c => c.Type == "sub")?.Value; + + if (string.IsNullOrEmpty(oidcSubject)) + { + _logger.LogError("OIDC-Claims enthalten keine Subject-ID"); + TempData["Error"] = "OIDC-Anmeldung fehlgeschlagen: Keine Benutzer-ID erhalten."; + return RedirectToAction("Login"); + } + + var username = oidcClaims?.FirstOrDefault(c => c.Type == _oidcSettings.ClaimUsername)?.Value + ?? oidcClaims?.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value + ?? oidcClaims?.FirstOrDefault(c => c.Type == "name")?.Value + ?? oidcSubject; + + var email = oidcClaims?.FirstOrDefault(c => c.Type == _oidcSettings.ClaimEmail)?.Value + ?? oidcClaims?.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value + ?? $"{username}@oidc.local"; + + var user = await _context.Users.FirstOrDefaultAsync(u => u.OidcSubject == oidcSubject); + + if (user == null) + { + if (!_oidcSettings.AutoProvisionUsers) + { + _logger.LogWarning("OIDC-User {Subject} existiert nicht und Auto-Provisioning ist deaktiviert", oidcSubject); + TempData["Error"] = "Ihr Benutzerkonto existiert nicht. Bitte kontaktieren Sie den Administrator."; + return RedirectToAction("Login"); + } + + var existingUsername = await _context.Users.AnyAsync(u => u.Username == username); + if (existingUsername) + { + username = $"{username}_{oidcSubject[..Math.Min(8, oidcSubject.Length)]}"; + } + + user = new User + { + Username = username, + Email = email, + OidcSubject = oidcSubject, + Password = string.Empty, + IsActive = true, + CreatedAt = DateTime.UtcNow, + LastLogin = DateTime.UtcNow + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + _logger.LogInformation("OIDC-User {Username} wurde automatisch erstellt (Subject: {Subject})", username, oidcSubject); + } + else + { + if (!user.IsActive) + { + _logger.LogWarning("OIDC-User {Username} ist deaktiviert", user.Username); + TempData["Error"] = "Ihr Benutzerkonto ist deaktiviert."; + return RedirectToAction("Login"); + } + + user.LastLogin = DateTime.UtcNow; + user.Email = email; + await _context.SaveChangesAsync(); + } + + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email), + new Claim("LastLogin", user.LastLogin.ToString("o")), + new Claim("AuthMethod", "OIDC") + }; + + var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + + var authProperties = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8) + }; + + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal, authProperties); + + _logger.LogInformation("OIDC-User {Username} erfolgreich angemeldet", user.Username); + + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl); + } + + return RedirectToAction("Index", "Home"); + } + [AllowAnonymous] [HttpPost] [ValidateAntiForgeryToken] diff --git a/watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.Designer.cs b/watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.Designer.cs new file mode 100644 index 0000000..a51fec7 --- /dev/null +++ b/watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.Designer.cs @@ -0,0 +1,181 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using watcher_monitoring.Data; + +#nullable disable + +namespace watcher_monitoring.Migrations +{ + [DbContext(typeof(WatcherDbContext))] + [Migration("20260121084748_AddOidcSubjectToUser")] + partial class AddOidcSubjectToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.6"); + + modelBuilder.Entity("watcher_monitoring.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("watcher_monitoring.Models.Container", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContainerName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Containers"); + }); + + modelBuilder.Entity("watcher_monitoring.Models.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CpuCores") + .HasColumnType("INTEGER"); + + b.Property("CpuType") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiskSpace") + .HasColumnType("TEXT"); + + b.Property("GpuType") + .HasColumnType("TEXT"); + + b.Property("IPAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsOnline") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RamSize") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("Servers"); + }); + + modelBuilder.Entity("watcher_monitoring.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLogin") + .HasColumnType("TEXT"); + + b.Property("OidcSubject") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("watcher_monitoring.Models.ApiKey", b => + { + b.HasOne("watcher_monitoring.Models.User", "User") + .WithMany("ApiKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("watcher_monitoring.Models.User", b => + { + b.Navigation("ApiKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.cs b/watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.cs new file mode 100644 index 0000000..b3fa424 --- /dev/null +++ b/watcher-monitoring/Migrations/20260121084748_AddOidcSubjectToUser.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace watcher_monitoring.Migrations +{ + /// + public partial class AddOidcSubjectToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OidcSubject", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_OidcSubject", + table: "Users", + column: "OidcSubject", + unique: true, + filter: "OidcSubject IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_OidcSubject", + table: "Users"); + + migrationBuilder.DropColumn( + name: "OidcSubject", + table: "Users"); + } + } +} diff --git a/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs b/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs index 9e03f7e..0cfda56 100644 --- a/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs +++ b/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs @@ -139,6 +139,10 @@ namespace watcher_monitoring.Migrations b.Property("LastLogin") .HasColumnType("TEXT"); + b.Property("OidcSubject") + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.Property("Password") .IsRequired() .HasColumnType("TEXT"); diff --git a/watcher-monitoring/Models/User.cs b/watcher-monitoring/Models/User.cs index 6a4e4be..905a0bf 100644 --- a/watcher-monitoring/Models/User.cs +++ b/watcher-monitoring/Models/User.cs @@ -27,6 +27,11 @@ public class User public bool IsActive { get; set; } = true; + [StringLength(255)] + public string? OidcSubject { get; set; } + + public bool IsOidcUser => !string.IsNullOrEmpty(OidcSubject); + // Navigation Property: Ein User kann mehrere API-Keys haben public ICollection ApiKeys { get; set; } = new List(); } diff --git a/watcher-monitoring/Program.cs b/watcher-monitoring/Program.cs index c261e87..f9852ae 100644 --- a/watcher-monitoring/Program.cs +++ b/watcher-monitoring/Program.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.OpenApi.Models; using Serilog; +using watcher_monitoring.Configuration; using watcher_monitoring.Data; var builder = WebApplication.CreateBuilder(args); @@ -27,6 +30,10 @@ builder.Host.UseSerilog(); DotNetEnv.Env.Load(); builder.Configuration.AddEnvironmentVariables(); +// OIDC-Einstellungen laden +var oidcSettings = OidcSettings.FromEnvironment(); +builder.Services.AddSingleton(oidcSettings); + // Konfiguration laden var configuration = builder.Configuration; @@ -44,7 +51,7 @@ builder.Services.AddDbContext((serviceProvider, options) => builder.Services.AddControllersWithViews(); // Cookie-basierte Authentifizierung -builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) +var authBuilder = builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/Auth/Login"; @@ -57,6 +64,36 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc options.Cookie.SameSite = SameSiteMode.Lax; }); +// OIDC-Authentifizierung (wenn aktiviert) +if (oidcSettings.IsValid) +{ + Log.Information("OIDC-Authentifizierung aktiviert für Authority: {Authority}", oidcSettings.Authority); + authBuilder.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => + { + options.Authority = oidcSettings.Authority; + options.ClientId = oidcSettings.ClientId; + options.ClientSecret = oidcSettings.ClientSecret; + options.ResponseType = OpenIdConnectResponseType.Code; + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.CallbackPath = oidcSettings.CallbackPath; + options.SignedOutCallbackPath = "/signout-callback-oidc"; + + options.Scope.Clear(); + foreach (var scope in oidcSettings.GetScopes()) + { + options.Scope.Add(scope); + } + + options.TokenValidationParameters.NameClaimType = oidcSettings.ClaimUsername; + options.TokenValidationParameters.RoleClaimType = "roles"; + }); +} +else if (oidcSettings.Enabled) +{ + Log.Warning("OIDC ist aktiviert aber nicht korrekt konfiguriert. Erforderlich: OIDC_AUTHORITY, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"); +} + builder.Services.AddAuthorization(); // Health Checks diff --git a/watcher-monitoring/Views/Auth/Login.cshtml b/watcher-monitoring/Views/Auth/Login.cshtml index d9ec690..e7024c7 100644 --- a/watcher-monitoring/Views/Auth/Login.cshtml +++ b/watcher-monitoring/Views/Auth/Login.cshtml @@ -97,6 +97,21 @@ + + @if (ViewData["OidcEnabled"] is true) + { +
+ + } diff --git a/watcher-monitoring/watcher-monitoring.csproj b/watcher-monitoring/watcher-monitoring.csproj index 79f1c9c..84a6c9c 100644 --- a/watcher-monitoring/watcher-monitoring.csproj +++ b/watcher-monitoring/watcher-monitoring.csproj @@ -17,6 +17,7 @@ +