feat(admin): add session-backed auth flow
This commit is contained in:
parent
f3c8315c59
commit
4d7ddf81ee
7 changed files with 228 additions and 64 deletions
134
src/lib/server/admin/session.ts
Normal file
134
src/lib/server/admin/session.ts
Normal 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
|
||||
|
|
@ -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
8
src/lib/types/session.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export interface SessionUser {
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface AdminSession {
|
||||
user: SessionUser
|
||||
expiresAt: number
|
||||
}
|
||||
25
src/routes/admin/+layout.server.ts
Normal file
25
src/routes/admin/+layout.server.ts
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
38
src/routes/admin/login/+page.server.ts
Normal file
38
src/routes/admin/login/+page.server.ts
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue