feat: add cookie-based session authentication system

- Add admin user management with Argon2 password hashing
- Implement session management with ULID-based tokens and 7-day expiry
- Add authentication middleware for protected routes and API endpoints
- Forward validated session to SvelteKit via trusted X-Session-User header
- Refactor admin panel to use server-side authentication checks
This commit is contained in:
2026-01-06 11:33:38 -06:00
parent 16bf2b76f3
commit c6dd1dffb0
14 changed files with 793 additions and 120 deletions
+56 -75
View File
@@ -1,89 +1,70 @@
// Mock admin authentication store
// TODO: Replace with real backend authentication when ready
import type { AuthSession } from "$lib/admin-types";
const SESSION_KEY = "admin_session";
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Mock credentials (replace with backend auth)
const MOCK_USERNAME = "admin";
const MOCK_PASSWORD = "password";
class AuthStore {
private session = $state<AuthSession | null>(null);
private initialized = $state(false);
constructor() {
// Initialize from localStorage when the store is created
if (typeof window !== "undefined") {
this.loadSession();
}
}
get isAuthenticated(): boolean {
if (!this.session) return false;
const expiresAt = new Date(this.session.expiresAt);
const now = new Date();
if (now > expiresAt) {
this.logout();
return false;
}
return true;
}
get isInitialized(): boolean {
return this.initialized;
}
private loadSession() {
try {
const stored = localStorage.getItem(SESSION_KEY);
if (stored) {
const session = JSON.parse(stored) as AuthSession;
this.session = session;
}
} catch (error) {
console.error("Failed to load session:", error);
} finally {
this.initialized = true;
}
}
private saveSession() {
if (this.session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(this.session));
} else {
localStorage.removeItem(SESSION_KEY);
}
}
isAuthenticated = $state(false);
username = $state<string | null>(null);
async login(username: string, password: string): Promise<boolean> {
// TODO: Replace with real API call to /admin/api/login
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay
try {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
credentials: "include",
});
if (username === MOCK_USERNAME && password === MOCK_PASSWORD) {
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS);
if (response.ok) {
const data = await response.json();
this.isAuthenticated = true;
this.username = data.username;
return true;
}
this.session = {
token: `mock-token-${Date.now()}`,
expiresAt: expiresAt.toISOString(),
};
return false;
} catch (error) {
console.error("Login error:", error);
return false;
}
}
this.saveSession();
return true;
async logout(): Promise<void> {
try {
await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
} catch (error) {
console.error("Logout error:", error);
} finally {
this.isAuthenticated = false;
this.username = null;
}
}
async checkSession(): Promise<boolean> {
try {
const response = await fetch("/api/session", {
credentials: "include",
});
if (response.ok) {
const session = await response.json();
this.isAuthenticated = true;
this.username = session.username;
return true;
}
} catch (error) {
console.error("Session check error:", error);
}
this.isAuthenticated = false;
this.username = null;
return false;
}
logout() {
this.session = null;
this.saveSession();
setSession(username: string): void {
this.isAuthenticated = true;
this.username = username;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ request, url }) => {
// Login page doesn't require authentication
if (url.pathname === "/admin/login") {
return {};
}
// Read trusted header from Rust proxy (cannot be spoofed by client)
const sessionUser = request.headers.get("x-session-user");
if (!sessionUser) {
// Not authenticated - redirect to login with next parameter
throw redirect(
302,
`/admin/login?next=${encodeURIComponent(url.pathname + url.search)}`
);
}
return {
session: {
authenticated: true,
username: sessionUser,
},
};
};
+8 -1
View File
@@ -7,7 +7,7 @@
import { getAdminStats } from "$lib/api";
import type { AdminStats } from "$lib/admin-types";
let { children } = $props();
let { children, data } = $props();
let stats = $state<AdminStats | null>(null);
let loading = $state(true);
@@ -28,6 +28,13 @@
}
}
// Sync authStore with server session on mount
$effect(() => {
if (data?.session?.authenticated && data.session.username && !authStore.isAuthenticated) {
authStore.setSession(data.session.username);
}
});
// Load stats when component mounts or when authentication changes
$effect(() => {
if (authStore.isAuthenticated && !isLoginPage) {
-31
View File
@@ -1,31 +0,0 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { authStore } from "$lib/stores/auth.svelte";
export const ssr = false; // Admin is client-side only
export async function load({ url }) {
if (browser) {
// Wait for auth store to initialize
while (!authStore.isInitialized) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Allow access to login page without authentication
if (url.pathname === "/admin/login") {
// If already authenticated, redirect to dashboard
if (authStore.isAuthenticated) {
goto("/admin");
}
return {};
}
// Require authentication for all other admin pages
if (!authStore.isAuthenticated) {
goto("/admin/login");
return {};
}
}
return {};
}
+4 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte";
import AppWrapper from "$lib/components/AppWrapper.svelte";
@@ -19,7 +20,8 @@
const success = await authStore.login(username, password);
if (success) {
goto("/admin");
const nextUrl = $page.url.searchParams.get("next") || "/admin";
goto(nextUrl);
} else {
error = "Invalid username or password";
}
@@ -36,7 +38,7 @@
<title>Admin Login | xevion.dev</title>
</svelte:head>
<AppWrapper bgColor="bg-admin-bg">
<AppWrapper>
<div class="flex min-h-screen items-center justify-center px-4">
<div class="w-full max-w-md space-y-4">
<!-- Login Form -->
@@ -70,10 +72,6 @@
</div>
{/if}
<div class="text-xs text-admin-text-muted">
Mock credentials: <code class="rounded bg-admin-bg px-1 py-0.5">admin</code> / <code class="rounded bg-admin-bg px-1 py-0.5">password</code>
</div>
<Button
type="submit"
variant="primary"