feat(admin): add session-backed auth flow

This commit is contained in:
Justin Edmund 2025-10-07 05:22:39 -07:00
parent f3c8315c59
commit 4d7ddf81ee
7 changed files with 228 additions and 64 deletions

View file

@ -0,0 +1,134 @@
import { dev } from '$app/environment'
import type { Cookies } from '@sveltejs/kit'
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { SessionUser } from '$lib/types/session'
const SESSION_COOKIE_NAME = 'admin_session'
const SESSION_TTL_SECONDS = 60 * 60 * 12 // 12 hours
interface SessionPayload {
username: string
exp: number
}
function adminPassword(): string {
return process.env.ADMIN_PASSWORD ?? 'changeme'
}
function sessionSecret(): string {
return process.env.ADMIN_SESSION_SECRET ?? process.env.ADMIN_PASSWORD ?? 'changeme'
}
function signPayload(payload: string): Buffer {
const hmac = createHmac('sha256', sessionSecret())
hmac.update(payload)
hmac.update(adminPassword())
return hmac.digest()
}
function buildToken(payload: SessionPayload): string {
const payloadStr = JSON.stringify(payload)
const signature = signPayload(payloadStr).toString('base64url')
return `${Buffer.from(payloadStr, 'utf8').toString('base64url')}.${signature}`
}
function parseToken(token: string): SessionPayload | null {
const [encodedPayload, encodedSignature] = token.split('.')
if (!encodedPayload || !encodedSignature) return null
const payloadStr = Buffer.from(encodedPayload, 'base64url').toString('utf8')
let payload: SessionPayload
try {
payload = JSON.parse(payloadStr)
if (!payload || typeof payload.username !== 'string' || typeof payload.exp !== 'number') {
return null
}
} catch {
return null
}
const expectedSignature = signPayload(payloadStr)
let providedSignature: Buffer
try {
providedSignature = Buffer.from(encodedSignature, 'base64url')
} catch {
return null
}
if (expectedSignature.length !== providedSignature.length) {
return null
}
try {
if (!timingSafeEqual(expectedSignature, providedSignature)) {
return null
}
} catch {
return null
}
if (Date.now() > payload.exp) {
return null
}
return payload
}
export function validateAdminPassword(password: string): SessionUser | null {
const expected = adminPassword()
const providedBuf = Buffer.from(password)
const expectedBuf = Buffer.from(expected)
if (providedBuf.length !== expectedBuf.length) {
return null
}
try {
if (!timingSafeEqual(providedBuf, expectedBuf)) {
return null
}
} catch {
return null
}
return { username: 'admin' }
}
export function createSessionToken(user: SessionUser): string {
const payload: SessionPayload = {
username: user.username,
exp: Date.now() + SESSION_TTL_SECONDS * 1000
}
return buildToken(payload)
}
export function readSessionToken(token: string | undefined): SessionUser | null {
if (!token) return null
const payload = parseToken(token)
if (!payload) return null
return { username: payload.username }
}
export function setSessionCookie(cookies: Cookies, user: SessionUser) {
const token = createSessionToken(user)
cookies.set(SESSION_COOKIE_NAME, token, {
path: '/admin',
httpOnly: true,
secure: !dev,
sameSite: 'lax',
maxAge: SESSION_TTL_SECONDS
})
}
export function clearSessionCookie(cookies: Cookies) {
cookies.delete(SESSION_COOKIE_NAME, {
path: '/admin'
})
}
export function getSessionUser(cookies: Cookies): SessionUser | null {
const token = cookies.get(SESSION_COOKIE_NAME)
return readSessionToken(token)
}
export const ADMIN_SESSION_COOKIE = SESSION_COOKIE_NAME

View file

@ -1,4 +1,5 @@
import type { RequestEvent } from '@sveltejs/kit'
import { getSessionUser } from '$lib/server/admin/session'
// Response helpers
export function jsonResponse(data: any, status = 200): Response {
@ -72,6 +73,11 @@ export function toISOString(date: Date | string | null | undefined): string | nu
// Basic auth check (temporary until proper auth is implemented)
export function checkAdminAuth(event: RequestEvent): boolean {
const sessionUser = getSessionUser(event.cookies)
if (sessionUser) {
return true
}
const authHeader = event.request.headers.get('Authorization')
if (!authHeader) return false

8
src/lib/types/session.ts Normal file
View file

@ -0,0 +1,8 @@
export interface SessionUser {
username: string
}
export interface AdminSession {
user: SessionUser
expiresAt: number
}

View file

@ -0,0 +1,25 @@
import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
import { getSessionUser } from '$lib/server/admin/session'
const LOGIN_PATH = '/admin/login'
const DASHBOARD_PATH = '/admin'
function isLoginRoute(pathname: string) {
return pathname === LOGIN_PATH
}
export const load = (async (event) => {
const user = getSessionUser(event.cookies)
const pathname = event.url.pathname
if (!user && !isLoginRoute(pathname)) {
throw redirect(303, LOGIN_PATH)
}
if (user && isLoginRoute(pathname)) {
throw redirect(303, DASHBOARD_PATH)
}
return { user }
}) satisfies LayoutServerLoad

View file

@ -1,42 +1,24 @@
<script lang="ts">
import { page } from '$app/stores'
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import AdminNavBar from '$lib/components/admin/AdminNavBar.svelte'
import type { LayoutData } from './$types'
let { children } = $props()
// Check if user is authenticated
let isAuthenticated = $state(false)
let isLoading = $state(true)
onMount(() => {
// Check localStorage for auth token
const auth = localStorage.getItem('admin_auth')
if (auth) {
isAuthenticated = true
} else if ($page.url.pathname !== '/admin/login') {
// Redirect to login if not authenticated
goto('/admin/login')
}
isLoading = false
})
const { children, data } = $props<{ children: any; data: LayoutData }>()
const currentPath = $derived($page.url.pathname)
const isLoginRoute = $derived(currentPath === '/admin/login')
// Pages that should use the card metaphor (no .admin-content wrapper)
const cardLayoutPages = ['/admin']
const useCardLayout = $derived(cardLayoutPages.includes(currentPath))
</script>
{#if isLoading}
<div class="loading">Loading...</div>
{:else if !isAuthenticated && currentPath !== '/admin/login'}
<!-- Not authenticated and not on login page, redirect will happen in onMount -->
<div class="loading">Redirecting to login...</div>
{:else if currentPath === '/admin/login'}
{#if isLoginRoute}
<!-- On login page, show children without layout -->
{@render children()}
{:else if !data.user}
<!-- Server loader should redirect, but provide fallback -->
<div class="loading">Redirecting to login...</div>
{:else}
<!-- Authenticated, show admin layout -->
<div class="admin-container">

View file

@ -0,0 +1,38 @@
import { fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
import { clearSessionCookie, setSessionCookie, validateAdminPassword } from '$lib/server/admin/session'
export const load = (async ({ cookies }) => {
// Ensure we start with a clean session when hitting the login page
clearSessionCookie(cookies)
return {
form: {
message: null
}
}
}) satisfies PageServerLoad
export const actions = {
default: async ({ request, cookies }) => {
const formData = await request.formData()
const password = formData.get('password')
if (typeof password !== 'string' || password.trim().length === 0) {
return fail(400, {
message: 'Password is required'
})
}
const user = validateAdminPassword(password)
if (!user) {
return fail(401, {
message: 'Invalid password'
})
}
setSessionCookie(cookies, user)
throw redirect(303, '/admin')
}
} satisfies Actions

View file

@ -1,39 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation'
import Input from '$lib/components/admin/Input.svelte'
import type { PageData } from './$types'
const { form } = $props<{ form: PageData['form'] | undefined }>()
let password = $state('')
let error = $state('')
let isLoading = $state(false)
async function handleLogin(e: Event) {
e.preventDefault()
error = ''
isLoading = true
try {
// Test the password by making an authenticated request
const response = await fetch('/api/media', {
headers: {
Authorization: `Basic ${btoa(`admin:${password}`)}`
}
})
if (response.ok) {
// Store auth in localStorage
localStorage.setItem('admin_auth', btoa(`admin:${password}`))
goto('/admin')
} else if (response.status === 401) {
error = 'Invalid password'
} else {
error = 'Something went wrong'
}
} catch (err) {
error = 'Failed to connect to server'
} finally {
isLoading = false
}
}
const errorMessage = $derived(form?.message ?? null)
</script>
<svelte:head>
@ -42,24 +14,23 @@
<div class="login-page">
<div class="login-card">
<form onsubmit={handleLogin}>
<form method="POST">
<Input
type="password"
label="Password"
name="password"
bind:value={password}
required
autofocus
placeholder="Enter password"
disabled={isLoading}
autocomplete="current-password"
/>
{#if error}
<div class="error-message">{error}</div>
{#if errorMessage}
<div class="error-message">{errorMessage}</div>
{/if}
<button type="submit" disabled={isLoading} class="login-btn">
{isLoading ? 'Logging in...' : 'Login'}
</button>
<button type="submit" class="login-btn">Login</button>
</form>
</div>
</div>