feat: User-Management mit Token-basierter Authentifizierung
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
This commit is contained in:
1 parent
a1c54bf376
commit
9a59ae686d
13 files changed
+446
-3
No files matched your search
@@ -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<JsonElement>(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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+9
-1
@@ -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<AuthMiddleware>();
|
||||
|
||||
app.MapAuthEndpoints();
|
||||
app.MapChatEndpoint();
|
||||
app.MapHistoryEndpoint();
|
||||
app.MapSessionEndpoints();
|
||||
|
||||
@@ -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<string, string> _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 _);
|
||||
}
|
||||
}
|
||||
@@ -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<int>("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<UserRecord> GetUserByUsername(string username)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<UserRecord>(
|
||||
"SELECT * FROM users WHERE username = @username COLLATE NOCASE",
|
||||
new { username });
|
||||
}
|
||||
|
||||
public async Task<UserRecord> GetUser(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<UserRecord>(
|
||||
"SELECT * FROM users WHERE user_id = @userId",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UserRecord>> GetUsers()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<UserRecord>(
|
||||
"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") });
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+20
-1
@@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
<link rel="stylesheet" href="css/dashboard.css">
|
||||
<link rel="stylesheet" href="css/chat.css">
|
||||
@@ -28,12 +29,29 @@
|
||||
|
||||
<!-- Templates -->
|
||||
|
||||
<template id="tpl-login">
|
||||
<div class="page login-page">
|
||||
<div class="login-box">
|
||||
<h1 class="login-title">// gott</h1>
|
||||
<div class="login-form">
|
||||
<input type="text" id="loginUsername" placeholder="Benutzername" autocomplete="username" autofocus>
|
||||
<input type="password" id="loginPassword" placeholder="Passwort" autocomplete="current-password">
|
||||
<div class="login-error" id="loginError" hidden></div>
|
||||
<button id="loginBtn">Anmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-dashboard">
|
||||
<div class="page dashboard">
|
||||
<header>
|
||||
<button class="back-btn icon-btn" disabled></button>
|
||||
<h1 class="dashboard-title">// gott</h1>
|
||||
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
|
||||
<div class="dashboard-links">
|
||||
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
|
||||
<a id="dashboardLogoutLink" class="dashboard-link">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-content">
|
||||
<section>
|
||||
@@ -287,6 +305,7 @@
|
||||
<script src="js/pages/agent-page.js"></script>
|
||||
<script src="js/pages/session-page.js"></script>
|
||||
<script src="js/pages/chat-page.js"></script>
|
||||
<script src="js/pages/login-page.js"></script>
|
||||
<script src="js/pages/settings-page.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,44 @@
|
||||
// ── Global 401 Handler ──
|
||||
|
||||
(function() {
|
||||
var _origFetch = window.fetch;
|
||||
window.fetch = function(url, opts) {
|
||||
return _origFetch.apply(this, arguments).then(function(resp) {
|
||||
if (resp.status === 401 && typeof url === 'string' && !url.includes('/api/auth/')) {
|
||||
if (typeof currentPage !== 'undefined' && currentPage !== 'login') {
|
||||
navigateTo('login');
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
// ── Auth API ──
|
||||
|
||||
async function apiLogin(username, password) {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.error || 'Login failed');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
async function apiMe() {
|
||||
const resp = await fetch('/api/auth/me');
|
||||
if (!resp.ok) return null;
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── API Calls ──
|
||||
|
||||
async function apiChat(messages, sessionId, signal) {
|
||||
|
||||
+17
-1
@@ -1,12 +1,28 @@
|
||||
// ── App Init ──
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Auth-Check: Eingeloggt?
|
||||
var user = await apiMe();
|
||||
if (!user) {
|
||||
await navigateTo('login');
|
||||
return;
|
||||
}
|
||||
|
||||
const { page, id } = parseHash();
|
||||
await navigateTo(page, { id });
|
||||
var target = page === 'login' ? 'dashboard' : page;
|
||||
await navigateTo(target, { id });
|
||||
});
|
||||
|
||||
// Hash-Änderungen abfangen
|
||||
window.addEventListener('hashchange', async () => {
|
||||
const { page, id } = parseHash();
|
||||
// Login-Seite immer erlauben, alles andere nur wenn eingeloggt
|
||||
if (page !== 'login') {
|
||||
var user = await apiMe();
|
||||
if (!user) {
|
||||
await navigateTo('login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
await navigateTo(page, { id });
|
||||
});
|
||||
@@ -8,6 +8,13 @@ registerPage('dashboard', {
|
||||
navigateTo('settings');
|
||||
});
|
||||
|
||||
// Logout-Link
|
||||
document.getElementById('dashboardLogoutLink').addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await apiLogout();
|
||||
navigateTo('login');
|
||||
});
|
||||
|
||||
// Globaler Click-Handler zum Schließen aller Card-Menüs
|
||||
document.addEventListener('click', this._closeCardMenus = () => closeAllCardMenus());
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// ── Login Page ──
|
||||
|
||||
registerPage('login', {
|
||||
init() {
|
||||
const usernameInput = $('#loginUsername');
|
||||
const passwordInput = $('#loginPassword');
|
||||
const loginBtn = $('#loginBtn');
|
||||
const errorDiv = $('#loginError');
|
||||
|
||||
async function doLogin() {
|
||||
errorDiv.hidden = true;
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Anmelden...';
|
||||
|
||||
try {
|
||||
var username = usernameInput.value.trim();
|
||||
var password = passwordInput.value;
|
||||
|
||||
if (!username || !password) {
|
||||
errorDiv.textContent = 'Benutzername und Passwort eingeben';
|
||||
errorDiv.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await apiLogin(username, password);
|
||||
navigateTo('dashboard');
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message || 'Login fehlgeschlagen';
|
||||
errorDiv.hidden = false;
|
||||
passwordInput.value = '';
|
||||
passwordInput.focus();
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Anmelden';
|
||||
}
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', doLogin);
|
||||
|
||||
usernameInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') passwordInput.focus();
|
||||
});
|
||||
|
||||
passwordInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
},
|
||||
destroy() {}
|
||||
});
|
||||
Reference in new issue
Block a user