From 9a59ae686dc190d0fdd87b2d18cefe79e9e82e83 Mon Sep 17 00:00:00 2001 From: Meleki Date: Mon, 2 Mar 2026 11:29:09 +0100 Subject: [PATCH] feat: User-Management mit Token-basierter Authentifizierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - UserRecord Model + Users-Tabelle in DB - AuthService: SHA256-Passwort-Hashing, Token-Verwaltung (ConcurrentDictionary) - AuthMiddleware: Schützt alle /api/-Routes (außer /api/auth/login) - AuthEndpoint: Login, Logout, Me - Default-User admin/admin wird automatisch erstellt Frontend: - Login-Page mit Formular (Enter-Key-Support) - Globaler Fetch-Interceptor: 401 → automatisch Login-Redirect - Auth-Check beim App-Start und bei Hash-Änderungen - Logout-Link im Dashboard-Header - Login-CSS: Zentrierte Login-Box --- Endpoints/AuthEndpoint.cs | 97 ++++++++++++++++++++++++++++++++++ Middleware/AuthMiddleware.cs | 40 ++++++++++++++ Models/UserRecord.cs | 12 +++++ Program.cs | 10 +++- Services/AuthService.cs | 41 ++++++++++++++ Services/DatabaseService.cs | 66 +++++++++++++++++++++++ wwwroot/css/dashboard.css | 11 ++++ wwwroot/css/login.css | 36 +++++++++++++ wwwroot/index.html | 21 +++++++- wwwroot/js/api.js | 41 ++++++++++++++ wwwroot/js/app.js | 18 ++++++- wwwroot/js/pages/dashboard.js | 7 +++ wwwroot/js/pages/login-page.js | 49 +++++++++++++++++ 13 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 Endpoints/AuthEndpoint.cs create mode 100644 Middleware/AuthMiddleware.cs create mode 100644 Models/UserRecord.cs create mode 100644 Services/AuthService.cs create mode 100644 wwwroot/css/login.css create mode 100644 wwwroot/js/pages/login-page.js diff --git a/Endpoints/AuthEndpoint.cs b/Endpoints/AuthEndpoint.cs new file mode 100644 index 0000000..45ebab1 --- /dev/null +++ b/Endpoints/AuthEndpoint.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Gott.Endpoints; + +public static class AuthEndpoint +{ + public static void MapAuthEndpoints(this WebApplication app) + { + app.MapPost("/api/auth/login", HandleLogin); + app.MapPost("/api/auth/logout", HandleLogout); + app.MapGet("/api/auth/me", HandleMe); + } + + private static async System.Threading.Tasks.Task HandleLogin(HttpContext ctx, DatabaseService db, AuthService auth) + { + var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + var username = body.TryGetProperty("username", out var u) ? u.GetString() : null; + var password = body.TryGetProperty("password", out var p) ? p.GetString() : null; + + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Username and password required" }); + return; + } + + var user = await db.GetUserByUsername(username); + if (user == null || !auth.VerifyPassword(password, user.PasswordHash)) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsJsonAsync(new { error = "Invalid credentials" }); + return; + } + + var token = auth.CreateToken(user.UserId); + + ctx.Response.Cookies.Append("gott_token", token, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Path = "/", + MaxAge = System.TimeSpan.FromDays(365) + }); + + await ctx.Response.WriteAsJsonAsync(new + { + userId = user.UserId, + username = user.Username, + displayName = user.DisplayName + }); + } + + private static async System.Threading.Tasks.Task HandleLogout(HttpContext ctx, AuthService auth) + { + var token = ctx.Request.Cookies["gott_token"]; + auth.RevokeToken(token); + + ctx.Response.Cookies.Delete("gott_token", new CookieOptions + { + Path = "/" + }); + + await ctx.Response.WriteAsJsonAsync(new { ok = true }); + } + + private static async System.Threading.Tasks.Task HandleMe(HttpContext ctx, DatabaseService db, AuthService auth) + { + var token = ctx.Request.Cookies["gott_token"]; + var userId = auth.ValidateToken(token); + + if (userId == null) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsJsonAsync(new { error = "Not authenticated" }); + return; + } + + var user = await db.GetUser(userId); + if (user == null) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsJsonAsync(new { error = "User not found" }); + return; + } + + await ctx.Response.WriteAsJsonAsync(new + { + userId = user.UserId, + username = user.Username, + displayName = user.DisplayName + }); + } +} diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs new file mode 100644 index 0000000..2944df0 --- /dev/null +++ b/Middleware/AuthMiddleware.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Gott.Services; +using Microsoft.AspNetCore.Http; + +namespace Gott.Middleware; + +public class AuthMiddleware +{ + private readonly RequestDelegate _next; + + public AuthMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext ctx, AuthService auth) + { + var path = ctx.Request.Path.Value ?? ""; + + // Static files and login endpoint don't require auth + if (!path.StartsWith("/api/") || path == "/api/auth/login") + { + await _next(ctx); + return; + } + + var token = ctx.Request.Cookies["gott_token"]; + var userId = auth.ValidateToken(token); + + if (userId == null) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsJsonAsync(new { error = "Not authenticated" }); + return; + } + + ctx.Items["UserId"] = userId; + await _next(ctx); + } +} diff --git a/Models/UserRecord.cs b/Models/UserRecord.cs new file mode 100644 index 0000000..2514b5a --- /dev/null +++ b/Models/UserRecord.cs @@ -0,0 +1,12 @@ +namespace Gott.Models; + +public class UserRecord +{ + public int Id { get; set; } + public string UserId { get; set; } + public string Username { get; set; } + public string PasswordHash { get; set; } + public string DisplayName { get; set; } + public string CreatedAt { get; set; } + public string UpdatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index aeb4e15..3ede814 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,7 @@ using System; using System.IO; using Gott.Endpoints; +using Gott.Middleware; using Gott.Services; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -10,17 +11,24 @@ builder.Services.AddHttpClient(); var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); +var authService = new AuthService(); +var dbService = new DatabaseService(Path.Combine(dataDir, "gott.db")); +dbService.EnsureDefaultUser(authService); + +builder.Services.AddSingleton(authService); +builder.Services.AddSingleton(dbService); builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new StreamingTracker()); builder.Services.AddSingleton(new MemoryService(dataDir)); builder.Services.AddSingleton(new SkillService(dataDir)); -builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); +app.UseMiddleware(); +app.MapAuthEndpoints(); app.MapChatEndpoint(); app.MapHistoryEndpoint(); app.MapSessionEndpoints(); diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..280cbdd --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace Gott.Services; + +public class AuthService +{ + private readonly ConcurrentDictionary _tokens = new(); // token -> userId + + public string HashPassword(string password) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password)); + return Convert.ToHexStringLower(bytes); + } + + public bool VerifyPassword(string password, string hash) + { + return HashPassword(password) == hash; + } + + public string CreateToken(string userId) + { + var token = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + _tokens[token] = userId; + return token; + } + + public string ValidateToken(string token) + { + if (string.IsNullOrEmpty(token)) return null; + return _tokens.TryGetValue(token, out var userId) ? userId : null; + } + + public void RevokeToken(string token) + { + if (!string.IsNullOrEmpty(token)) + _tokens.TryRemove(token, out _); + } +} diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 59dbac2..5e89857 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -155,6 +155,18 @@ public class DatabaseService ); CREATE INDEX IF NOT EXISTS idx_agent_skills_agent ON agent_skills(agent_id); "); + + conn.Execute(@" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + display_name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + "); } private void TryAddColumn(SqliteConnection conn, string table, string column, string type) @@ -638,4 +650,58 @@ public class DatabaseService "DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug", new { agentId, skillSlug }); } + + // ── Users ── + + public void EnsureDefaultUser(AuthService auth) + { + using var conn = CreateConnection(); + var count = conn.ExecuteScalar("SELECT COUNT(*) FROM users"); + if (count == 0) + { + var now = DateTime.UtcNow.ToString("o"); + conn.Execute(@" + INSERT INTO users (user_id, username, password_hash, display_name, created_at, updated_at) + VALUES (@userId, @username, @hash, @displayName, @now, @now)", + new + { + userId = Guid.NewGuid().ToString("N"), + username = "admin", + hash = auth.HashPassword("admin"), + displayName = "Admin", + now + }); + } + } + + public async Task GetUserByUsername(string username) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM users WHERE username = @username COLLATE NOCASE", + new { username }); + } + + public async Task GetUser(string userId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM users WHERE user_id = @userId", + new { userId }); + } + + public async Task> GetUsers() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM users ORDER BY username"); + } + + public async Task UpdateUserPassword(string userId, string passwordHash) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE users SET password_hash = @passwordHash, updated_at = @now WHERE user_id = @userId", + new { userId, passwordHash, now = DateTime.UtcNow.ToString("o") }); + } } diff --git a/wwwroot/css/dashboard.css b/wwwroot/css/dashboard.css index 2275feb..d2bd9f2 100644 --- a/wwwroot/css/dashboard.css +++ b/wwwroot/css/dashboard.css @@ -2,6 +2,13 @@ padding: var(--page-padding); } +.dashboard-links { + display: flex; + gap: 1.25rem; + align-items: center; + flex: 1; +} + .dashboard-link { color: var(--text); text-decoration: none; @@ -15,6 +22,10 @@ color: var(--text-accent); } +.dashboard-link:last-child { + margin-left: auto; +} + .dashboard section { margin-bottom: var(--page-spacing); width: 100%; diff --git a/wwwroot/css/login.css b/wwwroot/css/login.css new file mode 100644 index 0000000..445d5bd --- /dev/null +++ b/wwwroot/css/login.css @@ -0,0 +1,36 @@ +/* ── Login Page ── */ + +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + padding: 1.5rem; +} + +.login-box { + width: 100%; + max-width: 340px; +} + +.login-title { + text-align: center; + font-size: calc(var(--font-size) * 2); + margin-bottom: var(--box-spacing); + color: var(--fg); +} + +.login-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.login-error { + background: rgb(from var(--dangerous) r g b / 0.15); + padding: var(--box-padding); + color: var(--dangerous); + border-radius: var(--border-radius); + text-align: center; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 53703cd..52e1c6b 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -11,6 +11,7 @@ + @@ -28,12 +29,29 @@ + +