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:
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user