diff --git a/watcher-monitoring/Attributes/ApiKeyAuthAttribute.cs b/watcher-monitoring/Attributes/ApiKeyAuthAttribute.cs new file mode 100644 index 0000000..278b2ae --- /dev/null +++ b/watcher-monitoring/Attributes/ApiKeyAuthAttribute.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using watcher_monitoring.Data; + +namespace watcher_monitoring.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class ApiKeyAuthAttribute : Attribute, IAsyncActionFilter +{ + private const string ApiKeyHeaderName = "X-API-Key"; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeaderName, out var extractedApiKey)) + { + context.Result = new UnauthorizedObjectResult(new { error = "API-Key fehlt im Header" }); + return; + } + + var apiKeyString = extractedApiKey.ToString(); + + var dbContext = context.HttpContext.RequestServices.GetRequiredService(); + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + var apiKey = await dbContext.ApiKeys + .FirstOrDefaultAsync(k => k.Key == apiKeyString); + + if (apiKey == null) + { + logger.LogWarning("Ungültiger API-Key verwendet: {ApiKey}", apiKeyString); + context.Result = new UnauthorizedObjectResult(new { error = "Ungültiger API-Key" }); + return; + } + + if (!apiKey.IsActive) + { + logger.LogWarning("Inaktiver API-Key verwendet: {Name}", apiKey.Name); + context.Result = new UnauthorizedObjectResult(new { error = "API-Key ist deaktiviert" }); + return; + } + + if (apiKey.IsExpired) + { + logger.LogWarning("Abgelaufener API-Key verwendet: {Name}", apiKey.Name); + context.Result = new UnauthorizedObjectResult(new { error = "API-Key ist abgelaufen" }); + return; + } + + // Letzten Verwendungszeitpunkt aktualisieren + apiKey.LastUsedAt = DateTime.UtcNow; + await dbContext.SaveChangesAsync(); + + logger.LogInformation("API-Zugriff mit Key: {Name}", apiKey.Name); + + await next(); + } +} diff --git a/watcher-monitoring/Controllers/ApiController.cs b/watcher-monitoring/Controllers/ApiController.cs index 96a57a0..f8fd6da 100644 --- a/watcher-monitoring/Controllers/ApiController.cs +++ b/watcher-monitoring/Controllers/ApiController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using watcher_monitoring.Models; using watcher_monitoring.Data; +using watcher_monitoring.Attributes; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -12,6 +13,7 @@ namespace watcher_monitoring.Controllers; [ApiController] [Route("[controller]")] +[ApiKeyAuth] public class APIController : Controller { private readonly WatcherDbContext _context; diff --git a/watcher-monitoring/Controllers/ApiKeyController.cs b/watcher-monitoring/Controllers/ApiKeyController.cs new file mode 100644 index 0000000..19c1296 --- /dev/null +++ b/watcher-monitoring/Controllers/ApiKeyController.cs @@ -0,0 +1,153 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using watcher_monitoring.Data; +using watcher_monitoring.Models; + +namespace watcher_monitoring.Controllers; + +[ApiController] +[Route("[controller]")] +public class ApiKeyController : Controller +{ + private readonly WatcherDbContext _context; + private readonly ILogger _logger; + + public ApiKeyController(WatcherDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + // Generiert einen neuen API-Key + private static string GenerateApiKey() + { + var randomBytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes).Replace("+", "").Replace("/", "").Replace("=", ""); + } + + [HttpPost("create")] + public async Task CreateApiKey([FromBody] CreateApiKeyRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return BadRequest(new { error = "Name ist erforderlich" }); + } + + var apiKey = new ApiKey + { + Key = GenerateApiKey(), + Name = request.Name, + Description = request.Description, + ExpiresAt = request.ExpiresAt, + IsActive = true + }; + + _context.ApiKeys.Add(apiKey); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Neuer API-Key erstellt: {Name}", apiKey.Name); + + return Ok(new + { + id = apiKey.Id, + key = apiKey.Key, // Wird nur einmal zurückgegeben! + name = apiKey.Name, + description = apiKey.Description, + createdAt = apiKey.CreatedAt, + expiresAt = apiKey.ExpiresAt, + isActive = apiKey.IsActive + }); + } + + [HttpGet("list")] + public async Task ListApiKeys() + { + var apiKeys = await _context.ApiKeys + .OrderByDescending(k => k.CreatedAt) + .Select(k => new + { + id = k.Id, + name = k.Name, + description = k.Description, + createdAt = k.CreatedAt, + expiresAt = k.ExpiresAt, + lastUsedAt = k.LastUsedAt, + isActive = k.IsActive, + isExpired = k.IsExpired, + keyPreview = k.Key.Substring(0, Math.Min(8, k.Key.Length)) + "..." // Nur die ersten 8 Zeichen + }) + .ToListAsync(); + + return Ok(apiKeys); + } + + [HttpGet("{id}")] + public async Task GetApiKey(int id) + { + var apiKey = await _context.ApiKeys.FindAsync(id); + + if (apiKey == null) + { + return NotFound(new { error = "API-Key nicht gefunden" }); + } + + return Ok(new + { + id = apiKey.Id, + name = apiKey.Name, + description = apiKey.Description, + createdAt = apiKey.CreatedAt, + expiresAt = apiKey.ExpiresAt, + lastUsedAt = apiKey.LastUsedAt, + isActive = apiKey.IsActive, + isExpired = apiKey.IsExpired, + keyPreview = apiKey.Key.Substring(0, Math.Min(8, apiKey.Key.Length)) + "..." + }); + } + + [HttpPut("{id}/toggle")] + public async Task ToggleApiKey(int id) + { + var apiKey = await _context.ApiKeys.FindAsync(id); + + if (apiKey == null) + { + return NotFound(new { error = "API-Key nicht gefunden" }); + } + + apiKey.IsActive = !apiKey.IsActive; + await _context.SaveChangesAsync(); + + _logger.LogInformation("API-Key {Name} wurde {Status}", apiKey.Name, apiKey.IsActive ? "aktiviert" : "deaktiviert"); + + return Ok(new { isActive = apiKey.IsActive }); + } + + [HttpDelete("{id}")] + public async Task DeleteApiKey(int id) + { + var apiKey = await _context.ApiKeys.FindAsync(id); + + if (apiKey == null) + { + return NotFound(new { error = "API-Key nicht gefunden" }); + } + + _context.ApiKeys.Remove(apiKey); + await _context.SaveChangesAsync(); + + _logger.LogInformation("API-Key gelöscht: {Name}", apiKey.Name); + + return Ok(new { message = "API-Key erfolgreich gelöscht" }); + } +} + +public class CreateApiKeyRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime? ExpiresAt { get; set; } +} diff --git a/watcher-monitoring/Controllers/AuthController.cs b/watcher-monitoring/Controllers/AuthController.cs new file mode 100644 index 0000000..f44a7fb --- /dev/null +++ b/watcher-monitoring/Controllers/AuthController.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using watcher_monitoring.Data; +using watcher_monitoring.Models; + +namespace watcher_monitoring.Controllers; + +public class AuthController : Controller +{ + private readonly WatcherDbContext _context; + private readonly ILogger _logger; + + public AuthController(WatcherDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + [AllowAnonymous] + [HttpGet] + public IActionResult Login(string? returnUrl = null) + { + // Wenn der Benutzer bereits angemeldet ist, zur Startseite weiterleiten + if (User.Identity?.IsAuthenticated == true) + { + return RedirectToAction("Index", "Home"); + } + + ViewData["ReturnUrl"] = returnUrl; + return View(); + } + + [AllowAnonymous] + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Login(LoginViewModel model, string? returnUrl = null) + { + ViewData["ReturnUrl"] = returnUrl; + + if (!ModelState.IsValid) + { + return View(model); + } + + try + { + // Benutzer suchen + var user = await _context.Users + .FirstOrDefaultAsync(u => u.Username == model.Username && u.IsActive); + + if (user == null) + { + _logger.LogWarning("Login-Versuch mit ungültigem Benutzernamen: {Username}", model.Username); + TempData["Error"] = "Ungültiger Benutzername oder Passwort"; + return View(model); + } + + // Passwort überprüfen (BCrypt) + if (!BCrypt.Net.BCrypt.Verify(model.Password, user.Password)) + { + _logger.LogWarning("Login-Versuch mit falschem Passwort für Benutzer: {Username}", model.Username); + TempData["Error"] = "Ungültiger Benutzername oder Passwort"; + return View(model); + } + + // LastLogin aktualisieren + user.LastLogin = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + // Claims erstellen + 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")) + }; + + var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + + var authProperties = new AuthenticationProperties + { + IsPersistent = model.RememberMe, + ExpiresUtc = model.RememberMe ? DateTimeOffset.UtcNow.AddDays(30) : DateTimeOffset.UtcNow.AddHours(8) + }; + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + claimsPrincipal, + authProperties); + + _logger.LogInformation("Benutzer {Username} erfolgreich angemeldet", user.Username); + + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl); + } + + return RedirectToAction("Index", "Home"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Fehler beim Login-Vorgang"); + TempData["Error"] = "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."; + return View(model); + } + } + + [Authorize] + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Logout() + { + var username = User.Identity?.Name ?? "Unbekannt"; + + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + _logger.LogInformation("Benutzer {Username} erfolgreich abgemeldet", username); + + return RedirectToAction("Login", "Auth"); + } + + [AllowAnonymous] + public IActionResult AccessDenied() + { + return View(); + } +} diff --git a/watcher-monitoring/Controllers/HomeController.cs b/watcher-monitoring/Controllers/HomeController.cs index 37ffeb5..7f722d6 100644 --- a/watcher-monitoring/Controllers/HomeController.cs +++ b/watcher-monitoring/Controllers/HomeController.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using watcher_monitoring.Models; @@ -8,6 +9,7 @@ using Microsoft.EntityFrameworkCore; namespace watcher_monitoring.Controllers; +[Authorize] public class HomeController : Controller { private readonly WatcherDbContext _context; diff --git a/watcher-monitoring/Controllers/UserController.cs b/watcher-monitoring/Controllers/UserController.cs new file mode 100644 index 0000000..742eae1 --- /dev/null +++ b/watcher-monitoring/Controllers/UserController.cs @@ -0,0 +1,311 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using watcher_monitoring.Data; +using watcher_monitoring.Models; +using BCrypt.Net; + +namespace watcher_monitoring.Controllers; + +[Authorize] +[Route("[controller]")] +public class UserController : Controller +{ + private readonly WatcherDbContext _context; + private readonly ILogger _logger; + + public UserController(WatcherDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + // GET: /User + [HttpGet] + public async Task Index() + { + var users = await _context.Users + .Include(u => u.ApiKeys) + .OrderByDescending(u => u.CreatedAt) + .ToListAsync(); + + return View(users); + } + + // GET: /User/Details/{id} + [HttpGet("Details/{id}")] + public async Task Details(int id) + { + var user = await _context.Users + .Include(u => u.ApiKeys) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + return NotFound(); + } + + return View(user); + } + + // GET: /User/Create + [HttpGet("Create")] + public IActionResult Create() + { + return View(); + } + + // POST: /User/Create + [HttpPost("Create")] + [ValidateAntiForgeryToken] + public async Task Create([Bind("Username,Email,Password")] User user) + { + if (ModelState.IsValid) + { + // Prüfen, ob Username oder Email bereits existiert + var existingUser = await _context.Users + .FirstOrDefaultAsync(u => u.Username == user.Username || u.Email == user.Email); + + if (existingUser != null) + { + if (existingUser.Username == user.Username) + { + ModelState.AddModelError("Username", "Benutzername ist bereits vergeben"); + } + if (existingUser.Email == user.Email) + { + ModelState.AddModelError("Email", "E-Mail-Adresse ist bereits registriert"); + } + return View(user); + } + + // Passwort hashen mit BCrypt + user.Password = BCrypt.Net.BCrypt.HashPassword(user.Password); + user.CreatedAt = DateTime.UtcNow; + user.LastLogin = DateTime.UtcNow; + user.IsActive = true; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Neuer User erstellt: {Username}", user.Username); + + return RedirectToAction(nameof(Index)); + } + + return View(user); + } + + // GET: /User/Edit/{id} + [HttpGet("Edit/{id}")] + public async Task Edit(int id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + return View(user); + } + + // POST: /User/Edit/{id} + [HttpPost("Edit/{id}")] + [ValidateAntiForgeryToken] + public async Task Edit(int id, [Bind("Id,Username,Email,IsActive")] User updatedUser) + { + if (id != updatedUser.Id) + { + return NotFound(); + } + + if (ModelState.IsValid) + { + try + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + // Prüfen, ob neuer Username oder Email bereits von anderem User verwendet wird + var duplicate = await _context.Users + .FirstOrDefaultAsync(u => u.Id != id && (u.Username == updatedUser.Username || u.Email == updatedUser.Email)); + + if (duplicate != null) + { + if (duplicate.Username == updatedUser.Username) + { + ModelState.AddModelError("Username", "Benutzername ist bereits vergeben"); + } + if (duplicate.Email == updatedUser.Email) + { + ModelState.AddModelError("Email", "E-Mail-Adresse ist bereits registriert"); + } + return View(updatedUser); + } + + user.Username = updatedUser.Username; + user.Email = updatedUser.Email; + user.IsActive = updatedUser.IsActive; + + _context.Update(user); + await _context.SaveChangesAsync(); + + _logger.LogInformation("User aktualisiert: {Username}", user.Username); + + return RedirectToAction(nameof(Index)); + } + catch (DbUpdateConcurrencyException) + { + if (!await UserExists(updatedUser.Id)) + { + return NotFound(); + } + throw; + } + } + + return View(updatedUser); + } + + // POST: /User/Delete/{id} + [HttpPost("Delete/{id}")] + [ValidateAntiForgeryToken] + public async Task Delete(int id) + { + var user = await _context.Users + .Include(u => u.ApiKeys) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + return NotFound(); + } + + // Alle API-Keys des Users löschen + _context.ApiKeys.RemoveRange(user.ApiKeys); + _context.Users.Remove(user); + await _context.SaveChangesAsync(); + + _logger.LogInformation("User gelöscht: {Username}", user.Username); + + return RedirectToAction(nameof(Index)); + } + + // POST: /User/ToggleActive/{id} + [HttpPost("ToggleActive/{id}")] + [ValidateAntiForgeryToken] + public async Task ToggleActive(int id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + user.IsActive = !user.IsActive; + await _context.SaveChangesAsync(); + + _logger.LogInformation("User {Username} wurde {Status}", user.Username, user.IsActive ? "aktiviert" : "deaktiviert"); + + return RedirectToAction(nameof(Details), new { id }); + } + + // POST: /User/GenerateApiKey/{id} + [HttpPost("GenerateApiKey/{id}")] + [ValidateAntiForgeryToken] + public async Task GenerateApiKey(int id, string keyName, string? description, DateTime? expiresAt) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + if (string.IsNullOrWhiteSpace(keyName)) + { + TempData["Error"] = "API-Key-Name ist erforderlich"; + return RedirectToAction(nameof(Details), new { id }); + } + + var apiKey = new ApiKey + { + Key = GenerateApiKeyString(), + Name = keyName, + Description = description, + ExpiresAt = expiresAt, + IsActive = true, + UserId = user.Id + }; + + _context.ApiKeys.Add(apiKey); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Neuer API-Key für User {Username} erstellt: {KeyName}", user.Username, keyName); + + TempData["Success"] = $"API-Key erstellt: {apiKey.Key}"; + TempData["NewApiKey"] = apiKey.Key; + + return RedirectToAction(nameof(Details), new { id }); + } + + // POST: /User/DeleteApiKey/{userId}/{keyId} + [HttpPost("DeleteApiKey/{userId}/{keyId}")] + [ValidateAntiForgeryToken] + public async Task DeleteApiKey(int userId, int keyId) + { + var apiKey = await _context.ApiKeys + .FirstOrDefaultAsync(k => k.Id == keyId && k.UserId == userId); + + if (apiKey == null) + { + return NotFound(); + } + + _context.ApiKeys.Remove(apiKey); + await _context.SaveChangesAsync(); + + _logger.LogInformation("API-Key gelöscht: {KeyName}", apiKey.Name); + + TempData["Success"] = "API-Key erfolgreich gelöscht"; + + return RedirectToAction(nameof(Details), new { id = userId }); + } + + // POST: /User/ToggleApiKey/{userId}/{keyId} + [HttpPost("ToggleApiKey/{userId}/{keyId}")] + [ValidateAntiForgeryToken] + public async Task ToggleApiKey(int userId, int keyId) + { + var apiKey = await _context.ApiKeys + .FirstOrDefaultAsync(k => k.Id == keyId && k.UserId == userId); + + if (apiKey == null) + { + return NotFound(); + } + + apiKey.IsActive = !apiKey.IsActive; + await _context.SaveChangesAsync(); + + _logger.LogInformation("API-Key {KeyName} wurde {Status}", apiKey.Name, apiKey.IsActive ? "aktiviert" : "deaktiviert"); + + return RedirectToAction(nameof(Details), new { id = userId }); + } + + private async Task UserExists(int id) + { + return await _context.Users.AnyAsync(u => u.Id == id); + } + + private static string GenerateApiKeyString() + { + var randomBytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes).Replace("+", "").Replace("/", "").Replace("=", ""); + } +} diff --git a/watcher-monitoring/Data/WatcherDbContext.cs b/watcher-monitoring/Data/WatcherDbContext.cs index beccd6b..61d178e 100644 --- a/watcher-monitoring/Data/WatcherDbContext.cs +++ b/watcher-monitoring/Data/WatcherDbContext.cs @@ -21,4 +21,6 @@ public class WatcherDbContext : DbContext public DbSet Containers { get; set; } + public DbSet ApiKeys { get; set; } + } \ No newline at end of file diff --git a/watcher-monitoring/Migrations/20260109080705_AddApiKeys.Designer.cs b/watcher-monitoring/Migrations/20260109080705_AddApiKeys.Designer.cs new file mode 100644 index 0000000..20f5d70 --- /dev/null +++ b/watcher-monitoring/Migrations/20260109080705_AddApiKeys.Designer.cs @@ -0,0 +1,150 @@ +// +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("20260109080705_AddApiKeys")] + partial class AddApiKeys + { + /// + 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.HasKey("Id"); + + 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("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastLogin") + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/watcher-monitoring/Migrations/20260109080705_AddApiKeys.cs b/watcher-monitoring/Migrations/20260109080705_AddApiKeys.cs new file mode 100644 index 0000000..a316a6d --- /dev/null +++ b/watcher-monitoring/Migrations/20260109080705_AddApiKeys.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace watcher_monitoring.Migrations +{ + /// + public partial class AddApiKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Key = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: true), + LastUsedAt = table.Column(type: "TEXT", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys"); + } + } +} diff --git a/watcher-monitoring/Migrations/20260109081821_AddUserApiKeyRelationship.Designer.cs b/watcher-monitoring/Migrations/20260109081821_AddUserApiKeyRelationship.Designer.cs new file mode 100644 index 0000000..86af637 --- /dev/null +++ b/watcher-monitoring/Migrations/20260109081821_AddUserApiKeyRelationship.Designer.cs @@ -0,0 +1,177 @@ +// +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("20260109081821_AddUserApiKeyRelationship")] + partial class AddUserApiKeyRelationship + { + /// + 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("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/20260109081821_AddUserApiKeyRelationship.cs b/watcher-monitoring/Migrations/20260109081821_AddUserApiKeyRelationship.cs new file mode 100644 index 0000000..1d58dfd --- /dev/null +++ b/watcher-monitoring/Migrations/20260109081821_AddUserApiKeyRelationship.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace watcher_monitoring.Migrations +{ + /// + public partial class AddUserApiKeyRelationship : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Vorhandene API-Keys löschen, da sie keinem User zugeordnet werden können + migrationBuilder.Sql("DELETE FROM ApiKeys;"); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Users", + type: "TEXT", + nullable: false, + defaultValue: DateTime.UtcNow); + + migrationBuilder.AddColumn( + name: "IsActive", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "ApiKeys", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_UserId", + table: "ApiKeys", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_ApiKeys_Users_UserId", + table: "ApiKeys", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ApiKeys_Users_UserId", + table: "ApiKeys"); + + migrationBuilder.DropIndex( + name: "IX_ApiKeys_UserId", + table: "ApiKeys"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsActive", + table: "Users"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "ApiKeys"); + } + } +} diff --git a/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs b/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs index a75b290..9e03f7e 100644 --- a/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs +++ b/watcher-monitoring/Migrations/WatcherDbContextModelSnapshot.cs @@ -17,6 +17,47 @@ namespace watcher_monitoring.Migrations #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") @@ -85,10 +126,16 @@ namespace watcher_monitoring.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + b.Property("Email") .IsRequired() .HasColumnType("TEXT"); + b.Property("IsActive") + .HasColumnType("INTEGER"); + b.Property("LastLogin") .HasColumnType("TEXT"); @@ -105,6 +152,22 @@ namespace watcher_monitoring.Migrations 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/Models/ApiKey.cs b/watcher-monitoring/Models/ApiKey.cs new file mode 100644 index 0000000..509240b --- /dev/null +++ b/watcher-monitoring/Models/ApiKey.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace watcher_monitoring.Models; + +public class ApiKey +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(64)] + public string Key { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? ExpiresAt { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public bool IsActive { get; set; } = true; + + public bool IsExpired => ExpiresAt.HasValue && ExpiresAt.Value < DateTime.UtcNow; + + // Foreign Key: Jeder API-Key gehört zu einem User + public int UserId { get; set; } + + // Navigation Property + public User User { get; set; } = null!; +} diff --git a/watcher-monitoring/Models/LoginViewModel.cs b/watcher-monitoring/Models/LoginViewModel.cs new file mode 100644 index 0000000..bef5b37 --- /dev/null +++ b/watcher-monitoring/Models/LoginViewModel.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace watcher_monitoring.Models; + +public class LoginViewModel +{ + [Required(ErrorMessage = "Benutzername ist erforderlich")] + [Display(Name = "Benutzername")] + public string Username { get; set; } = string.Empty; + + [Required(ErrorMessage = "Passwort ist erforderlich")] + [DataType(DataType.Password)] + [Display(Name = "Passwort")] + public string Password { get; set; } = string.Empty; + + [Display(Name = "Angemeldet bleiben")] + public bool RememberMe { get; set; } +} diff --git a/watcher-monitoring/Models/User.cs b/watcher-monitoring/Models/User.cs index f458a7a..6a4e4be 100644 --- a/watcher-monitoring/Models/User.cs +++ b/watcher-monitoring/Models/User.cs @@ -22,4 +22,11 @@ public class User [Required] [DataType(DataType.Password)] public required string Password { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public bool IsActive { get; set; } = true; + + // 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 612a5ea..3fc2669 100644 --- a/watcher-monitoring/Program.cs +++ b/watcher-monitoring/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi.Models; @@ -41,6 +42,22 @@ builder.Services.AddDbContext((serviceProvider, options) => // Add services to the container. builder.Services.AddControllersWithViews(); +// Cookie-basierte Authentifizierung +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/Auth/Login"; + options.LogoutPath = "/Auth/Logout"; + options.AccessDeniedPath = "/Auth/AccessDenied"; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Lax; + }); + +builder.Services.AddAuthorization(); + // Health Checks builder.Services.AddHealthChecks(); @@ -69,6 +86,24 @@ using (var scope = app.Services.CreateScope()) Log.Information("Führe Datenbank-Migrationen aus..."); dbContext.Database.Migrate(); Log.Information("Datenbank-Migrationen erfolgreich angewendet"); + + // Standard-Admin-User erstellen, falls noch kein User existiert + if (!dbContext.Users.Any()) + { + Log.Information("Erstelle Standard-Admin-User..."); + var adminUser = new watcher_monitoring.Models.User + { + Username = "admin", + Email = "admin@watcher.local", + Password = BCrypt.Net.BCrypt.HashPassword("admin"), + IsActive = true, + CreatedAt = DateTime.UtcNow, + LastLogin = DateTime.UtcNow + }; + dbContext.Users.Add(adminUser); + dbContext.SaveChanges(); + Log.Information("Standard-Admin-User erstellt (Username: admin, Passwort: admin)"); + } } catch (Exception ex) { @@ -98,6 +133,7 @@ app.UseSwaggerUI(options => options.RoutePrefix = "api/v1/swagger"; }); +app.UseAuthentication(); app.UseAuthorization(); // Health Check Endpoint diff --git a/watcher-monitoring/Views/Auth/AccessDenied.cshtml b/watcher-monitoring/Views/Auth/AccessDenied.cshtml new file mode 100644 index 0000000..50af1d5 --- /dev/null +++ b/watcher-monitoring/Views/Auth/AccessDenied.cshtml @@ -0,0 +1,18 @@ +@{ + ViewData["Title"] = "Zugriff verweigert"; +} + +
+
+
+
+
+

🚫

+

Zugriff verweigert

+

Sie haben keine Berechtigung, auf diese Seite zuzugreifen.

+ Zurück zum Login +
+
+
+
+
diff --git a/watcher-monitoring/Views/Auth/Login.cshtml b/watcher-monitoring/Views/Auth/Login.cshtml new file mode 100644 index 0000000..d9ec690 --- /dev/null +++ b/watcher-monitoring/Views/Auth/Login.cshtml @@ -0,0 +1,108 @@ +@model watcher_monitoring.Models.LoginViewModel + +@{ + ViewData["Title"] = "Login"; + Layout = null; +} + + + + + + + @ViewData["Title"] - Watcher Monitoring + + + + + + + + + + + + + diff --git a/watcher-monitoring/Views/Shared/_Layout.cshtml b/watcher-monitoring/Views/Shared/_Layout.cshtml index c69088d..45ae1dd 100644 --- a/watcher-monitoring/Views/Shared/_Layout.cshtml +++ b/watcher-monitoring/Views/Shared/_Layout.cshtml @@ -1,5 +1,6 @@  + @@ -8,11 +9,13 @@ +