From d44db1489c41cf85d2f5280ea580d6e7367f441e Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 22 Feb 2026 04:52:17 +0000 Subject: [PATCH] trips) add local auth provider and login picker --- .env.example | 9 ++ README.md | 13 +++ package.json | 3 +- src/auth.ts | 32 +++++- src/lib/server/db/migrations.ts | 9 ++ src/lib/server/local-auth.test.ts | 84 +++++++++++++++ src/lib/server/local-auth.ts | 146 ++++++++++++++++++++++++++ src/lib/server/local-credentials.ts | 67 ++++++++++++ src/routes/login/+page.server.test.ts | 37 +++++++ src/routes/login/+page.server.ts | 13 ++- src/routes/login/+page.svelte | 87 ++++++++++++--- 11 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 src/lib/server/local-auth.test.ts create mode 100644 src/lib/server/local-auth.ts create mode 100644 src/lib/server/local-credentials.ts create mode 100644 src/routes/login/+page.server.test.ts diff --git a/.env.example b/.env.example index ebcc086..59361a3 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,15 @@ AUTH_URL=https://cloud.campbellwireless.net/trips/auth # Auth.js secret β€” generate with: openssl rand -base64 32 AUTH_SECRET= +# Local auth +LOCAL_AUTH_ENABLED=false +LOCAL_AUTH_ARGON2_MEMORY_KB=65536 +LOCAL_AUTH_ARGON2_TIME_COST=3 +LOCAL_AUTH_ARGON2_PARALLELISM=1 +LOCAL_AUTH_MAX_ATTEMPTS=5 +LOCAL_AUTH_WINDOW_SECONDS=900 +LOCAL_AUTH_LOCKOUT_SECONDS=900 + # Admin β€” comma-separated user IDs (from auth provider) that can access /admin ADMIN_USER_IDS= diff --git a/README.md b/README.md index e2ae38f..3a3818b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,19 @@ bun install bun run dev ``` +## Local authentication + +Local auth is optional and off by default. Enable it with the env vars in `.env.example` and ensure users have a matching row in both `users` and `local_credentials`. + +To seed a local password hash, use Argon2id with the configured parameters and insert it into `local_credentials`: + +```sql +INSERT INTO local_credentials (user_id, password_hash) +VALUES ('', ''); +``` + +Passwords must be at least 12 characters. Avoid storing plaintext passwords anywhere. + ## Quality and Tests ```sh diff --git a/package.json b/package.json index 3539a01..eb7a9e2 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vitest": "^4.0.18" }, "dependencies": { - "@auth/sveltekit": "^1.11.1" + "@auth/sveltekit": "^1.11.1", + "argon2": "^0.41.1" } } diff --git a/src/auth.ts b/src/auth.ts index a73dbf9..4fb449b 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,6 +1,8 @@ import { SvelteKitAuth } from '@auth/sveltekit'; +import Credentials from '@auth/core/providers/credentials'; import { env } from '$env/dynamic/private'; import { upsertUserFromAuth } from '$lib/server/users.js'; +import { verifyLocalCredentials } from '$lib/server/local-auth.js'; export const { handle, signIn, signOut } = SvelteKitAuth({ providers: [ @@ -19,11 +21,34 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ email: profile.email as string | undefined }; } - } + }, + Credentials({ + id: 'local', + name: 'Local', + credentials: { + identifier: { label: 'Username or email', type: 'text' }, + password: { label: 'Password', type: 'password' } + }, + async authorize(credentials, request) { + const identifier = String(credentials?.identifier ?? '').trim(); + const password = String(credentials?.password ?? '').trim(); + const ip = + request?.headers?.get?.('x-forwarded-for') ?? + request?.headers?.get?.('x-real-ip') ?? + undefined; + const result = await verifyLocalCredentials(identifier, password, ip); + if (result.status !== 'success') return null; + return { + id: result.user.id, + name: result.user.name, + email: result.user.email ?? undefined + }; + } + }) ], trustHost: true, callbacks: { - jwt({ token, profile }) { + jwt({ token, profile, user }) { const details = profile as | { sub?: string; @@ -43,6 +68,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ authSource: 'OIDC - Synology' }); } + if (user?.id) { + token.sub = user.id as string; + } return token; }, session({ session, token }) { diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 3e65b0f..cf99467 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -65,6 +65,15 @@ export function runMigrations(db: Database): void { ) `); + db.run(` + CREATE TABLE IF NOT EXISTS local_credentials ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.run(` CREATE TABLE IF NOT EXISTS cities ( id INTEGER PRIMARY KEY, diff --git a/src/lib/server/local-auth.test.ts b/src/lib/server/local-auth.test.ts new file mode 100644 index 0000000..16a83b7 --- /dev/null +++ b/src/lib/server/local-auth.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupTestDb } from '../../tests/helpers.js'; +import type { Database } from './db/types.js'; +import { setLocalCredentialPassword } from './local-credentials.js'; +import { resetLocalAuthRateLimits, verifyLocalCredentials } from './local-auth.js'; +import { upsertUserFromAuth } from './users.js'; + +let database: Database; + +beforeEach(() => { + process.env.LOCAL_AUTH_ENABLED = 'true'; + process.env.LOCAL_AUTH_ARGON2_MEMORY_KB = '8192'; + process.env.LOCAL_AUTH_ARGON2_TIME_COST = '2'; + process.env.LOCAL_AUTH_ARGON2_PARALLELISM = '1'; + process.env.LOCAL_AUTH_MAX_ATTEMPTS = '2'; + process.env.LOCAL_AUTH_WINDOW_SECONDS = '60'; + process.env.LOCAL_AUTH_LOCKOUT_SECONDS = '10'; + database = setupTestDb(); + resetLocalAuthRateLimits(); +}); + +afterEach(() => { + vi.useRealTimers(); + resetLocalAuthRateLimits(); + database.close(); +}); + +describe('verifyLocalCredentials', () => { + it('authenticates valid local credentials', async () => { + upsertUserFromAuth({ + id: 'user-1', + username: 'jdoe', + fullName: 'Jane Doe', + email: 'jane@example.com', + authSource: 'Local' + }); + await setLocalCredentialPassword('user-1', 'averysecurepassword'); + + const result = await verifyLocalCredentials('jdoe', 'averysecurepassword', '127.0.0.1'); + + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.user.id).toBe('user-1'); + expect(result.user.email).toBe('jane@example.com'); + } + }); + + it('returns invalid for bad credentials and locks after max attempts', async () => { + upsertUserFromAuth({ id: 'user-2', username: 'sally', fullName: 'Sally Sample' }); + await setLocalCredentialPassword('user-2', 'averysecurepassword'); + + const first = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1'); + const second = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1'); + const third = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1'); + + expect(first.status).toBe('invalid'); + expect(second.status).toBe('invalid'); + expect(third.status).toBe('locked'); + }); + + it('clears lockout after window expires', async () => { + upsertUserFromAuth({ id: 'user-3', username: 'morgan', fullName: 'Morgan West' }); + await setLocalCredentialPassword('user-3', 'averysecurepassword'); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2'); + await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2'); + const locked = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2'); + + expect(locked.status).toBe('locked'); + + vi.advanceTimersByTime(11_000); + const after = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2'); + + expect(after.status).toBe('success'); + }); + + it('rejects when local auth is disabled', async () => { + process.env.LOCAL_AUTH_ENABLED = 'false'; + const result = await verifyLocalCredentials('anyone', 'password', '10.0.0.3'); + expect(result.status).toBe('disabled'); + }); +}); diff --git a/src/lib/server/local-auth.ts b/src/lib/server/local-auth.ts new file mode 100644 index 0000000..20f8093 --- /dev/null +++ b/src/lib/server/local-auth.ts @@ -0,0 +1,146 @@ +import { env } from '$env/dynamic/private'; +import argon2 from 'argon2'; +import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js'; + +export interface LocalAuthUser { + id: string; + name: string; + email?: string | null; +} + +export type LocalAuthResult = + | { status: 'success'; user: LocalAuthUser } + | { status: 'invalid' | 'locked' | 'disabled' }; + +interface AttemptState { + count: number; + firstAttemptAt: number; + lockedUntil?: number; +} + +const attemptsByIdentifier = new Map(); +const attemptsByIp = new Map(); +let dummyHashPromise: Promise | null = null; + +const getNumberEnv = (value: string | undefined, fallback: number): number => { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const getLocalAuthConfig = () => { + return { + enabled: env.LOCAL_AUTH_ENABLED === 'true', + maxAttempts: getNumberEnv(env.LOCAL_AUTH_MAX_ATTEMPTS, 5), + windowMs: getNumberEnv(env.LOCAL_AUTH_WINDOW_SECONDS, 900) * 1000, + lockoutMs: getNumberEnv(env.LOCAL_AUTH_LOCKOUT_SECONDS, 900) * 1000 + }; +}; + +const normalizeIdentifier = (value: string): string => value.trim().toLowerCase(); +const normalizePassword = (value: string): string => value.trim(); + +const normalizeIp = (value?: string | null): string => { + if (!value) return 'unknown'; + const trimmed = value.trim(); + if (!trimmed) return 'unknown'; + return trimmed.split(',')[0]?.trim() || 'unknown'; +}; + +const getDummyHash = async (): Promise => { + if (!dummyHashPromise) { + dummyHashPromise = hashLocalPassword('invalid-password-placeholder'); + } + return dummyHashPromise; +}; + +const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => { + if (!state) return { count: 0, firstAttemptAt: now }; + if (state.lockedUntil && state.lockedUntil <= now) { + return { count: 0, firstAttemptAt: now }; + } + if (now - state.firstAttemptAt > windowMs) { + return { count: 0, firstAttemptAt: now }; + } + return { ...state }; +}; + +const isLocked = (state: AttemptState, now: number): boolean => { + return Boolean(state.lockedUntil && state.lockedUntil > now); +}; + +const recordFailure = ( + map: Map, + key: string, + now: number, + windowMs: number, + lockoutMs: number, + maxAttempts: number +): void => { + const state = getEffectiveState(map.get(key), now, windowMs); + const nextCount = state.count + 1; + const nextState: AttemptState = { + count: nextCount, + firstAttemptAt: state.firstAttemptAt + }; + if (nextCount >= maxAttempts) { + nextState.lockedUntil = now + lockoutMs; + } + map.set(key, nextState); +}; + +const clearAttemptState = (map: Map, key: string): void => { + map.delete(key); +}; + +export function resetLocalAuthRateLimits(): void { + attemptsByIdentifier.clear(); + attemptsByIp.clear(); +} + +export async function verifyLocalCredentials( + identifierInput: string, + passwordInput: string, + ipAddress?: string | null +): Promise { + const config = getLocalAuthConfig(); + if (!config.enabled) return { status: 'disabled' }; + + const identifier = normalizeIdentifier(identifierInput ?? ''); + const password = normalizePassword(passwordInput ?? ''); + const identifierKey = identifier || 'unknown'; + const ipKey = normalizeIp(ipAddress); + const now = Date.now(); + + const identifierState = getEffectiveState(attemptsByIdentifier.get(identifierKey), now, config.windowMs); + const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs); + const locked = isLocked(identifierState, now) || isLocked(ipState, now); + + const record = identifier ? getLocalCredentialByIdentifier(identifier) : null; + const passwordHash = record?.passwordHash ?? (await getDummyHash()); + const passwordMatches = await argon2.verify(passwordHash, password); + + if (!locked && record && passwordMatches) { + clearAttemptState(attemptsByIdentifier, identifierKey); + clearAttemptState(attemptsByIp, ipKey); + return { + status: 'success', + user: { + id: record.userId, + name: record.fullName || record.username, + email: record.email ?? undefined + } + }; + } + + recordFailure( + attemptsByIdentifier, + identifierKey, + now, + config.windowMs, + config.lockoutMs, + config.maxAttempts + ); + recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts); + + return { status: locked ? 'locked' : 'invalid' }; +} diff --git a/src/lib/server/local-credentials.ts b/src/lib/server/local-credentials.ts new file mode 100644 index 0000000..0e3dd13 --- /dev/null +++ b/src/lib/server/local-credentials.ts @@ -0,0 +1,67 @@ +import { env } from '$env/dynamic/private'; +import argon2 from 'argon2'; +import { db } from './db/index.js'; + +export interface LocalCredentialRecord { + userId: string; + username: string; + fullName: string; + email: string | null; + passwordHash: string; +} + +export const LOCAL_AUTH_MIN_PASSWORD_LENGTH = 12; + +const getNumberEnv = (value: string | undefined, fallback: number): number => { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +export function getLocalCredentialByIdentifier(identifier: string): LocalCredentialRecord | null { + const normalized = identifier.trim(); + if (!normalized) return null; + return ( + db.get( + `SELECT + users.id as userId, + users.username as username, + users.full_name as fullName, + users.email as email, + local_credentials.password_hash as passwordHash + FROM users + INNER JOIN local_credentials ON local_credentials.user_id = users.id + WHERE lower(users.username) = lower(?) + OR (users.email IS NOT NULL AND lower(users.email) = lower(?)) + LIMIT 1`, + [normalized, normalized] + ) ?? null + ); +} + +export async function hashLocalPassword(password: string): Promise { + const trimmed = password.trim(); + if (trimmed.length < LOCAL_AUTH_MIN_PASSWORD_LENGTH) { + throw new Error(`Password must be at least ${LOCAL_AUTH_MIN_PASSWORD_LENGTH} characters`); + } + const memoryCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_MEMORY_KB, 65536); + const timeCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_TIME_COST, 3); + const parallelism = getNumberEnv(env.LOCAL_AUTH_ARGON2_PARALLELISM, 1); + return argon2.hash(trimmed, { + type: argon2.argon2id, + memoryCost, + timeCost, + parallelism + }); +} + +export async function setLocalCredentialPassword(userId: string, password: string): Promise { + const passwordHash = await hashLocalPassword(password); + db.run( + `INSERT INTO local_credentials (user_id, password_hash) + VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET + password_hash = excluded.password_hash, + updated_at = datetime('now')`, + [userId, passwordHash] + ); +} diff --git a/src/routes/login/+page.server.test.ts b/src/routes/login/+page.server.test.ts new file mode 100644 index 0000000..4c4d6ff --- /dev/null +++ b/src/routes/login/+page.server.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { load } from './+page.server'; + +beforeEach(() => { + process.env.AUTH_URL = 'https://example.com/auth'; +}); + +describe('login page load', () => { + it('returns auth options when local auth is enabled', async () => { + process.env.LOCAL_AUTH_ENABLED = 'true'; + const result = await load({ + url: new URL('https://example.com/login') + } as Parameters[0]); + + expect(result.signinUrl).toBe('https://example.com/auth/signin/synology'); + expect(result.localSigninUrl).toBe('https://example.com/auth/signin/local'); + expect(result.localAuthEnabled).toBe(true); + }); + + it('maps credentials errors to a generic message', async () => { + process.env.LOCAL_AUTH_ENABLED = 'true'; + const result = await load({ + url: new URL('https://example.com/login?error=CredentialsSignin') + } as Parameters[0]); + + expect(result.error).toBe('Invalid credentials'); + }); + + it('disables local auth in the response when disabled', async () => { + process.env.LOCAL_AUTH_ENABLED = 'false'; + const result = await load({ + url: new URL('https://example.com/login') + } as Parameters[0]); + + expect(result.localAuthEnabled).toBe(false); + }); +}); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index c9a1406..74acf5b 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,8 +1,17 @@ import { env } from '$env/dynamic/private'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { +const resolveErrorMessage = (value: string | null): string | null => { + if (!value) return null; + if (value === 'CredentialsSignin') return 'Invalid credentials'; + return null; +}; + +export const load: PageServerLoad = async ({ url }) => { return { - signinUrl: `${env.AUTH_URL}/signin/synology` + signinUrl: `${env.AUTH_URL}/signin/synology`, + localSigninUrl: `${env.AUTH_URL}/signin/local`, + localAuthEnabled: env.LOCAL_AUTH_ENABLED === 'true', + error: resolveErrorMessage(url.searchParams.get('error')) }; }; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0b3b47e..e89c471 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,22 +1,85 @@ Sign in β€” Trips - +
+
+

Sign in

+

+ Choose a sign-in method to access Trips. +

+
-
-

Redirecting to sign in…

+ {#if data.error} +
+ {data.error} +
+ {/if} + +
+
+

Synology account

+

+ Use your Synology SSO account. +

+
+ + +
+
+ +
+

Local account

+

+ Sign in with your local username or email. +

+ {#if data.localAuthEnabled} +
+ +
+ + +
+
+ + +
+ +
+ {:else} +
+ Local sign-in is disabled. +
+ {/if} +
+