Add session-based authentication with login/logout
- Users and sessions tables (Argon2 password hashing, SHA-256 session tokens) - Server hooks validate session cookie on every request - (app) routes redirect to /login if not authenticated - Login page with email/password, styled matching budget app - Logout via POST form action (invalidates session) - User display name and sign out button in header - create-user CLI script: npm run create-user <email> <password> [name] - 30-day sessions with auto-refresh after 15 days Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+4
-1
@@ -2,7 +2,10 @@
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {}
|
||||
interface Locals {
|
||||
user: { id: string; email: string; displayName: string | null } | null;
|
||||
session: { id: string; userId: string; expiresAt: Date; fresh: boolean } | null;
|
||||
}
|
||||
interface PageData {}
|
||||
interface Platform {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { validateSession, setSessionCookie, deleteSessionCookie } from '$lib/server/auth/index.js';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const token = event.cookies.get('session');
|
||||
|
||||
if (token) {
|
||||
const { session, user } = await validateSession(token);
|
||||
|
||||
if (session && user) {
|
||||
event.locals.user = user;
|
||||
event.locals.session = session;
|
||||
|
||||
// Refresh cookie if session was extended
|
||||
if (session.fresh) {
|
||||
setSessionCookie(event, token, session.expiresAt);
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
deleteSessionCookie(event);
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { sessions, users } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const SESSION_REFRESH_MS = 15 * 24 * 60 * 60 * 1000; // 15 days
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
const bytes = new Uint8Array(20);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase32LowerCaseNoPadding(bytes);
|
||||
}
|
||||
|
||||
export function generateUserId(): string {
|
||||
const bytes = new Uint8Array(15);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase32LowerCaseNoPadding(bytes);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
const encoded = new TextEncoder().encode(token);
|
||||
return encodeHexLowerCase(sha256(encoded));
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const id = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
|
||||
await db.insert(sessions).values({ id, userId, expiresAt });
|
||||
|
||||
return { id, userId, expiresAt, fresh: false };
|
||||
}
|
||||
|
||||
export async function validateSession(token: string) {
|
||||
const id = hashToken(token);
|
||||
|
||||
const [result] = await db
|
||||
.select({
|
||||
sessionId: sessions.id,
|
||||
userId: sessions.userId,
|
||||
expiresAt: sessions.expiresAt,
|
||||
userEmail: users.email,
|
||||
userDisplayName: users.displayName
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(eq(sessions.id, id));
|
||||
|
||||
if (!result) return { session: null, user: null };
|
||||
|
||||
if (result.expiresAt < new Date()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, id));
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: result.sessionId,
|
||||
userId: result.userId,
|
||||
expiresAt: result.expiresAt,
|
||||
fresh: false
|
||||
};
|
||||
|
||||
// Refresh if older than 15 days
|
||||
if (Date.now() - (result.expiresAt.getTime() - SESSION_DURATION_MS) > SESSION_REFRESH_MS) {
|
||||
session.expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
session.fresh = true;
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ expiresAt: session.expiresAt })
|
||||
.where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: result.userId,
|
||||
email: result.userEmail,
|
||||
displayName: result.userDisplayName
|
||||
};
|
||||
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function invalidateSession(token: string) {
|
||||
const id = hashToken(token);
|
||||
await db.delete(sessions).where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
export function setSessionCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set('session', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: event.url.protocol === 'https:',
|
||||
path: '/',
|
||||
expires: expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionCookie(event: RequestEvent) {
|
||||
event.cookies.set('session', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: event.url.protocol === 'https:',
|
||||
path: '/',
|
||||
maxAge: 0
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
const ARGON2_OPTIONS = {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1
|
||||
};
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
|
||||
return verify(passwordHash, password, ARGON2_OPTIONS);
|
||||
}
|
||||
@@ -10,6 +10,25 @@ import {
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
// ─── Users & Sessions ──────────────────────────────────────────────
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').unique().notNull(),
|
||||
displayName: text('display_name'),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
id: text('id').primaryKey(), // SHA-256 hash of the session token
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
|
||||
});
|
||||
|
||||
// ─── Locations ──────────────────────────────────────────────────────
|
||||
|
||||
export const locations = pgTable('locations', {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { devices, components } from '$lib/server/db/schema.js';
|
||||
import { count, eq, or, and } from 'drizzle-orm';
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
redirect(302, '/login');
|
||||
}
|
||||
|
||||
const [deviceCount] = await db.select({ value: count() }).from(devices).where(eq(devices.disabled, false));
|
||||
const [componentCount] = await db.select({ value: count() }).from(components);
|
||||
const [repairCount] = await db
|
||||
@@ -12,6 +17,7 @@ export const load: LayoutServerLoad = async () => {
|
||||
.where(and(eq(devices.disabled, false), or(eq(devices.condition, 'In Repair'), eq(devices.condition, 'Waiting for Repair'))));
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
counts: {
|
||||
devices: deviceCount?.value ?? 0,
|
||||
components: componentCount?.value ?? 0,
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{data.user.displayName ?? data.user.email}</span>
|
||||
<form method="POST" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import ThemeToggle from '$lib/components/layout/ThemeToggle.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div class="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { verifyPassword } from '$lib/server/auth/password.js';
|
||||
import {
|
||||
generateSessionToken,
|
||||
createSession,
|
||||
setSessionCookie
|
||||
} from '$lib/server/auth/index.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user) {
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const formData = await event.request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, { error: 'Email and password are required', email });
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email.toLowerCase().trim()));
|
||||
|
||||
if (!user) {
|
||||
return fail(400, { error: 'Invalid email or password', email });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(user.passwordHash, password);
|
||||
if (!valid) {
|
||||
return fail(400, { error: 'Invalid email or password', email });
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, user.id);
|
||||
setSessionCookie(event, token, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign In - B4L Repair</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-center text-xl font-bold text-gray-900 dark:text-white">B4L Repair</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
value={form?.email ?? ''}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { invalidateSession, deleteSessionCookie } from '$lib/server/auth/index.js';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(302, '/login');
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const token = event.cookies.get('session');
|
||||
if (token) {
|
||||
await invalidateSession(token);
|
||||
}
|
||||
deleteSessionCookie(event);
|
||||
redirect(302, '/login');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user