mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user