mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-26 04:42:00 +01:00
feat(auth): implement authentication system
- Username/password login with bcrypt and session cookies - API key authentication (X-Api-Key header or ?apikey query param) - AUTH env var modes: on, local, off, oidc - Generic OIDC support for external providers - Session metadata tracking (IP, browser, device) - Security settings page (password, sessions, API key) - Login analysis with typo and attack detection - Auth event logging throughout
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -39,3 +39,9 @@ obj/
|
||||
|
||||
# Bruno environments (contain API keys)
|
||||
bruno/environments/
|
||||
|
||||
# OIDC testing (local Keycloak config)
|
||||
test/oidc/
|
||||
|
||||
# Research repos
|
||||
research/
|
||||
|
||||
10
README.md
10
README.md
@@ -62,6 +62,16 @@
|
||||
- **Testing** - Validate regex patterns, custom format conditions, and quality
|
||||
profile behavior before syncing
|
||||
|
||||
**Authentication**
|
||||
|
||||
- `AUTH=on` (default) - Username/password login required
|
||||
- `AUTH=local` - Skip auth for local network requests
|
||||
- `AUTH=oidc` - SSO via OpenID Connect provider
|
||||
- `AUTH=off` - No authentication (use with external auth like Authentik/Authelia)
|
||||
|
||||
API access via `X-Api-Key` header or `?apikey=` query param. See
|
||||
[auth docs](src/lib/server/utils/auth/README.md) for details.
|
||||
|
||||
## Discord
|
||||
|
||||
We're most active on [Discord](https://discord.gg/2A89tXZMgA), where we post
|
||||
|
||||
10
deno.json
10
deno.json
@@ -19,16 +19,20 @@
|
||||
"$notifications/": "./src/lib/server/notifications/",
|
||||
"$sync/": "./src/lib/server/sync/",
|
||||
"$cache/": "./src/lib/server/utils/cache/",
|
||||
"$auth/": "./src/lib/server/utils/auth/",
|
||||
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.0",
|
||||
"marked": "npm:marked@^15.0.6",
|
||||
"simple-icons": "npm:simple-icons@^15.17.0",
|
||||
"highlight.js": "npm:highlight.js@^11.11.1",
|
||||
"croner": "npm:croner@^8.1.2",
|
||||
"@std/yaml": "jsr:@std/yaml@^1.0.10"
|
||||
"croner": "npm:croner@^9.1.0",
|
||||
"@std/yaml": "jsr:@std/yaml@^1.0.10",
|
||||
"@felix/bcrypt": "jsr:@felix/bcrypt@^1.0.8"
|
||||
},
|
||||
"tasks": {
|
||||
"dev": "deno run -A scripts/dev.ts",
|
||||
"dev:noauth": "AUTH=local deno run -A scripts/dev.ts",
|
||||
"dev:oidc": "AUTH=oidc OIDC_DISCOVERY_URL=http://localhost:8080/realms/profilarr/.well-known/openid-configuration OIDC_CLIENT_ID=profilarr OIDC_CLIENT_SECRET=secret deno run -A scripts/dev.ts",
|
||||
"dev:server": "DENO_ENV=development PORT=6969 HOST=0.0.0.0 APP_BASE_PATH=./dist/dev PARSER_HOST=localhost PARSER_PORT=5000 VITE_PLATFORM=linux-amd64 VITE_CHANNEL=dev deno run -A npm:vite dev",
|
||||
"dev:parser": "cd src/services/parser && dotnet watch run --urls http://localhost:5000",
|
||||
"build": "APP_BASE_PATH=./dist/build deno run -A npm:vite build && deno compile --no-check --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --allow-sys --target x86_64-unknown-linux-gnu --output dist/build/profilarr dist/build/mod.ts",
|
||||
@@ -37,7 +41,7 @@
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"check": "deno task check:server && deno task check:client",
|
||||
"check:server": "deno check src/lib/server/**/*.ts",
|
||||
"check:server": "deno check --quiet src/lib/server/**/*.ts",
|
||||
"check:client": "npx svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "deno run -A scripts/test.ts",
|
||||
"test:watch": "APP_BASE_PATH=./dist/test deno test src/tests --allow-read --allow-write --allow-env --watch",
|
||||
|
||||
1130
docs/todo/auth.md
Normal file
1130
docs/todo/auth.md
Normal file
File diff suppressed because it is too large
Load Diff
1
package-lock.json
generated
1
package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "profilarr",
|
||||
"version": "2.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@deno/vite-plugin": "^1.0.5",
|
||||
"@jsr/db__sqlite": "^0.12.0",
|
||||
|
||||
8
src/app.d.ts
vendored
8
src/app.d.ts
vendored
@@ -1,9 +1,15 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { User } from '$db/queries/users.ts';
|
||||
import type { Session } from '$db/queries/sessions.ts';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
6
src/deno.d.ts
vendored
6
src/deno.d.ts
vendored
@@ -7,3 +7,9 @@ declare const __APP_VERSION__: string;
|
||||
|
||||
// Note: Deno namespace types are provided by Deno's built-in lib.deno.ns.d.ts
|
||||
// Do not redeclare them here to avoid conflicts
|
||||
|
||||
// JSR package declarations for svelte-check compatibility
|
||||
declare module '@felix/bcrypt' {
|
||||
export function hash(password: string, rounds?: number): Promise<string>;
|
||||
export function verify(password: string, hash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { config } from '$config';
|
||||
import { printBanner, getServerInfo, logContainerConfig } from '$logger/startup.ts';
|
||||
import { logSettings } from '$logger/settings.ts';
|
||||
@@ -7,6 +9,13 @@ import { runMigrations } from '$db/migrations.ts';
|
||||
import { initializeJobs } from '$jobs/init.ts';
|
||||
import { jobScheduler } from '$jobs/scheduler.ts';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import {
|
||||
getAuthState,
|
||||
isPublicPath,
|
||||
maybeExtendSession,
|
||||
cleanupExpiredSessions
|
||||
} from '$auth/middleware.ts';
|
||||
import { getClientIp } from '$auth/network.ts';
|
||||
|
||||
// Initialize configuration on server startup
|
||||
await config.init();
|
||||
@@ -30,6 +39,15 @@ await pcdManager.initialize();
|
||||
await initializeJobs();
|
||||
await jobScheduler.start();
|
||||
|
||||
// Clean expired sessions on startup
|
||||
const expiredCount = cleanupExpiredSessions();
|
||||
if (expiredCount > 0) {
|
||||
await logger.info(`Cleaned up ${expiredCount} expired session${expiredCount === 1 ? '' : 's'}`, {
|
||||
source: 'Auth:Session',
|
||||
meta: { count: expiredCount }
|
||||
});
|
||||
}
|
||||
|
||||
// Log server ready
|
||||
await logger.info('Server ready', {
|
||||
source: 'Startup',
|
||||
@@ -38,3 +56,61 @@ await logger.info('Server ready', {
|
||||
|
||||
// Print startup banner with URL
|
||||
printBanner();
|
||||
|
||||
/**
|
||||
* Auth middleware
|
||||
* Handles authentication, authorization, and session management
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const auth = getAuthState(event);
|
||||
|
||||
// First-run setup flow (applies to all auth modes except AUTH=off)
|
||||
if (auth.needsSetup) {
|
||||
if (event.url.pathname === '/auth/setup') {
|
||||
return resolve(event);
|
||||
}
|
||||
throw redirect(303, '/auth/setup');
|
||||
}
|
||||
|
||||
// AUTH=off or AUTH=local with local IP - skip auth after setup
|
||||
if (auth.skipAuth) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Block setup page after user exists
|
||||
if (event.url.pathname === '/auth/setup') {
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
// Public paths don't need auth
|
||||
if (isPublicPath(event.url.pathname)) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Not authenticated - redirect or return 401
|
||||
if (!auth.user) {
|
||||
if (event.url.pathname.startsWith('/api')) {
|
||||
const ip = getClientIp(event);
|
||||
void logger.warn('Unauthorized API access', {
|
||||
source: 'Auth',
|
||||
meta: { ip, endpoint: event.url.pathname, method: event.request.method }
|
||||
});
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
throw redirect(303, '/auth/login');
|
||||
}
|
||||
|
||||
// Sliding expiration: extend session if past halfway point
|
||||
if (auth.session) {
|
||||
maybeExtendSession(auth.session);
|
||||
}
|
||||
|
||||
// Authenticated - attach user to locals for use in routes
|
||||
event.locals.user = auth.user;
|
||||
event.locals.session = auth.session;
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Eye, EyeOff } from 'lucide-svelte';
|
||||
|
||||
export let label: string;
|
||||
export let description: string = '';
|
||||
export let placeholder: string = '';
|
||||
@@ -6,12 +8,20 @@
|
||||
export let textarea: boolean = false;
|
||||
export let type: 'text' | 'number' | 'email' | 'password' | 'url' = 'text';
|
||||
export let required: boolean = false;
|
||||
export let name: string = '';
|
||||
export let autocomplete: string = '';
|
||||
export let private_: boolean = false;
|
||||
export let readonly: boolean = false;
|
||||
|
||||
let showPassword = false;
|
||||
|
||||
$: inputType = private_ && showPassword ? 'text' : type;
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<label for={name} class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{label}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if description}
|
||||
<p class="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
@@ -21,18 +31,49 @@
|
||||
|
||||
{#if textarea}
|
||||
<textarea
|
||||
id={name}
|
||||
{name}
|
||||
bind:value
|
||||
{placeholder}
|
||||
rows="6"
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
|
||||
class="block w-full rounded-xl border border-neutral-300 bg-white px-3 py-2 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-300 focus:outline-none dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-600"
|
||||
></textarea>
|
||||
{:else if private_}
|
||||
<div class="relative">
|
||||
<input
|
||||
id={name}
|
||||
{name}
|
||||
type={inputType}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{required}
|
||||
readonly={readonly}
|
||||
autocomplete={autocomplete ? (autocomplete as typeof HTMLInputElement.prototype.autocomplete) : undefined}
|
||||
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 pr-10 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeOff size={18} />
|
||||
{:else}
|
||||
<Eye size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
id={name}
|
||||
{name}
|
||||
{type}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{required}
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
|
||||
readonly={readonly}
|
||||
autocomplete={autocomplete ? (autocomplete as typeof HTMLInputElement.prototype.autocomplete) : undefined}
|
||||
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
<GroupItem label="Logs" href="/settings/logs" />
|
||||
<GroupItem label="Backups" href="/settings/backups" />
|
||||
<GroupItem label="Notifications" href="/settings/notifications" />
|
||||
<GroupItem label="Security" href="/settings/security" />
|
||||
<GroupItem label="About" href="/settings/about" />
|
||||
<GroupItem label="Log Out" href="/auth/logout" />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
export let onRowClick: ((row: T) => void) | undefined = undefined;
|
||||
export let initialSort: SortState | null = null;
|
||||
export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined;
|
||||
export let actionsHeader: string = 'Actions';
|
||||
|
||||
let sortKey: string | null = initialSort?.key ?? null;
|
||||
let sortDirection: SortDirection = initialSort?.direction ?? 'asc';
|
||||
@@ -169,7 +170,7 @@
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-right text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300`}
|
||||
>
|
||||
Actions
|
||||
{actionsHeader}
|
||||
</th>
|
||||
{/if}
|
||||
</tr>
|
||||
|
||||
@@ -37,6 +37,8 @@ import { migration as migration032 } from './migrations/032_add_filter_id_to_upg
|
||||
import { migration as migration033 } from './migrations/033_create_github_cache.ts';
|
||||
import { migration as migration034 } from './migrations/034_add_sync_status.ts';
|
||||
import { migration as migration035 } from './migrations/035_add_job_skipped_status.ts';
|
||||
import { migration as migration036 } from './migrations/036_create_auth_tables.ts';
|
||||
import { migration as migration037 } from './migrations/037_add_session_metadata.ts';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -292,7 +294,9 @@ export function loadMigrations(): Migration[] {
|
||||
migration032,
|
||||
migration033,
|
||||
migration034,
|
||||
migration035
|
||||
migration035,
|
||||
migration036,
|
||||
migration037
|
||||
];
|
||||
|
||||
// Sort by version number
|
||||
|
||||
58
src/lib/server/db/migrations/036_create_auth_tables.ts
Normal file
58
src/lib/server/db/migrations/036_create_auth_tables.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 036: Create authentication tables
|
||||
*
|
||||
* Creates all auth-related tables:
|
||||
* - users: Single admin user (this is a single-user app)
|
||||
* - sessions: Multiple sessions per user (allows login from multiple devices)
|
||||
* - auth_settings: Singleton for session duration and API key
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 36,
|
||||
name: 'Create auth tables',
|
||||
|
||||
up: `
|
||||
-- Users table (single admin user)
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Sessions table (multiple sessions per user)
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
-- Auth settings table (singleton)
|
||||
CREATE TABLE auth_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
session_duration_hours INTEGER NOT NULL DEFAULT 168,
|
||||
api_key TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default auth settings with generated API key
|
||||
INSERT INTO auth_settings (id, api_key) VALUES (1, lower(hex(randomblob(16))));
|
||||
`,
|
||||
|
||||
down: `
|
||||
DROP TABLE IF EXISTS auth_settings;
|
||||
DROP INDEX IF EXISTS idx_sessions_expires_at;
|
||||
DROP INDEX IF EXISTS idx_sessions_user_id;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
`
|
||||
};
|
||||
47
src/lib/server/db/migrations/037_add_session_metadata.ts
Normal file
47
src/lib/server/db/migrations/037_add_session_metadata.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 037: Add session metadata columns
|
||||
*
|
||||
* Adds rich session information for better session management UI:
|
||||
* - ip_address: Client IP address when session was created
|
||||
* - user_agent: Full user agent string
|
||||
* - browser: Parsed browser name and version (e.g., "Chrome 120")
|
||||
* - os: Parsed operating system (e.g., "Windows 11")
|
||||
* - device_type: Device category (Desktop, Mobile, Tablet)
|
||||
* - last_active_at: Updated on sliding expiration for activity tracking
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 37,
|
||||
name: 'Add session metadata',
|
||||
|
||||
up: `
|
||||
ALTER TABLE sessions ADD COLUMN ip_address TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN browser TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN os TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN device_type TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN last_active_at DATETIME;
|
||||
`,
|
||||
|
||||
down: `
|
||||
-- SQLite doesn't support DROP COLUMN directly, so we recreate the table
|
||||
CREATE TABLE sessions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO sessions_new (id, user_id, expires_at, created_at)
|
||||
SELECT id, user_id, expires_at, created_at FROM sessions;
|
||||
|
||||
DROP TABLE sessions;
|
||||
ALTER TABLE sessions_new RENAME TO sessions;
|
||||
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
`
|
||||
};
|
||||
104
src/lib/server/db/queries/authSettings.ts
Normal file
104
src/lib/server/db/queries/authSettings.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { db } from '../db.ts';
|
||||
import { generateApiKey } from '$auth/apiKey.ts';
|
||||
|
||||
/**
|
||||
* Types for auth_settings table
|
||||
*/
|
||||
export interface AuthSettings {
|
||||
id: number;
|
||||
session_duration_hours: number;
|
||||
api_key: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateAuthSettingsInput {
|
||||
sessionDurationHours?: number;
|
||||
apiKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All queries for auth_settings table
|
||||
* Singleton pattern - only one settings record exists
|
||||
*/
|
||||
export const authSettingsQueries = {
|
||||
/**
|
||||
* Get auth settings (singleton)
|
||||
*/
|
||||
get(): AuthSettings {
|
||||
const settings = db.queryFirst<AuthSettings>('SELECT * FROM auth_settings WHERE id = 1');
|
||||
if (!settings) {
|
||||
throw new Error('Auth settings not found - database may not be initialized');
|
||||
}
|
||||
return settings;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get session duration in hours
|
||||
*/
|
||||
getSessionDurationHours(): number {
|
||||
return this.get().session_duration_hours;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get API key (may be null)
|
||||
*/
|
||||
getApiKey(): string | null {
|
||||
return this.get().api_key;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update auth settings
|
||||
*/
|
||||
update(input: UpdateAuthSettingsInput): boolean {
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
if (input.sessionDurationHours !== undefined) {
|
||||
updates.push('session_duration_hours = ?');
|
||||
params.push(input.sessionDurationHours);
|
||||
}
|
||||
if (input.apiKey !== undefined) {
|
||||
updates.push('api_key = ?');
|
||||
params.push(input.apiKey);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(1); // id is always 1
|
||||
|
||||
const affected = db.execute(
|
||||
`UPDATE auth_settings SET ${updates.join(', ')} WHERE id = ?`,
|
||||
...params
|
||||
);
|
||||
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate API key and return the new key
|
||||
*/
|
||||
regenerateApiKey(): string {
|
||||
const newKey = generateApiKey();
|
||||
this.update({ apiKey: newKey });
|
||||
return newKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear API key (disable API access)
|
||||
*/
|
||||
clearApiKey(): boolean {
|
||||
return this.update({ apiKey: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate an API key
|
||||
*/
|
||||
validateApiKey(key: string): boolean {
|
||||
const settings = this.get();
|
||||
return settings.api_key !== null && settings.api_key === key;
|
||||
}
|
||||
};
|
||||
145
src/lib/server/db/queries/sessions.ts
Normal file
145
src/lib/server/db/queries/sessions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { db } from '../db.ts';
|
||||
|
||||
/**
|
||||
* Types for sessions table
|
||||
*/
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: number;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
// Metadata fields (Migration 037)
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
browser: string | null;
|
||||
os: string | null;
|
||||
device_type: string | null;
|
||||
last_active_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata to capture when creating a session
|
||||
*/
|
||||
export interface SessionMetadata {
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
deviceType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All queries for sessions table
|
||||
* Multiple sessions per user (different browsers/devices)
|
||||
*/
|
||||
export const sessionsQueries = {
|
||||
/**
|
||||
* Create a new session with optional metadata
|
||||
*/
|
||||
create(userId: number, durationHours: number, metadata?: SessionMetadata): string {
|
||||
const id = crypto.randomUUID();
|
||||
const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000);
|
||||
|
||||
db.execute(
|
||||
`INSERT INTO sessions (id, user_id, expires_at, ip_address, user_agent, browser, os, device_type, last_active_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
id,
|
||||
userId,
|
||||
expiresAt.toISOString(),
|
||||
metadata?.ipAddress ?? null,
|
||||
metadata?.userAgent ?? null,
|
||||
metadata?.browser ?? null,
|
||||
metadata?.os ?? null,
|
||||
metadata?.deviceType ?? null
|
||||
);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a session by ID (regardless of expiration)
|
||||
*/
|
||||
getById(id: string): Session | undefined {
|
||||
return db.queryFirst<Session>('SELECT * FROM sessions WHERE id = ?', id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a valid (non-expired) session by ID
|
||||
*/
|
||||
getValidById(id: string): Session | undefined {
|
||||
return db.queryFirst<Session>(
|
||||
`SELECT * FROM sessions
|
||||
WHERE id = ? AND datetime(expires_at) > datetime('now')`,
|
||||
id
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all sessions for a user
|
||||
*/
|
||||
getByUserId(userId: number): Session[] {
|
||||
return db.query<Session>(
|
||||
'SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC',
|
||||
userId
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a specific session (logout)
|
||||
*/
|
||||
deleteById(id: string): boolean {
|
||||
const affected = db.execute('DELETE FROM sessions WHERE id = ?', id);
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all sessions for a user (logout everywhere)
|
||||
*/
|
||||
deleteByUserId(userId: number): number {
|
||||
return db.execute('DELETE FROM sessions WHERE user_id = ?', userId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all sessions except one (logout other devices)
|
||||
*/
|
||||
deleteOthersByUserId(userId: number, keepSessionId: string): number {
|
||||
return db.execute(
|
||||
'DELETE FROM sessions WHERE user_id = ? AND id != ?',
|
||||
userId,
|
||||
keepSessionId
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all expired sessions (cleanup)
|
||||
*/
|
||||
deleteExpired(): number {
|
||||
return db.execute(`DELETE FROM sessions WHERE datetime(expires_at) <= datetime('now')`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Extend session expiration (sliding expiration)
|
||||
* Also updates last_active_at for activity tracking
|
||||
*/
|
||||
extendExpiration(id: string, durationHours: number): boolean {
|
||||
const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000);
|
||||
const affected = db.execute(
|
||||
'UPDATE sessions SET expires_at = ?, last_active_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
expiresAt.toISOString(),
|
||||
id
|
||||
);
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Count active sessions for a user
|
||||
*/
|
||||
countByUserId(userId: number): number {
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM sessions
|
||||
WHERE user_id = ? AND datetime(expires_at) > datetime('now')`,
|
||||
userId
|
||||
);
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
};
|
||||
122
src/lib/server/db/queries/users.ts
Normal file
122
src/lib/server/db/queries/users.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { db } from '../db.ts';
|
||||
|
||||
/**
|
||||
* Types for users table
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All queries for users table
|
||||
* Single admin user - no multi-user support
|
||||
*/
|
||||
export const usersQueries = {
|
||||
/**
|
||||
* Check if any users exist (for first-run setup detection)
|
||||
*/
|
||||
exists(): boolean {
|
||||
const result = db.queryFirst<{ count: number }>('SELECT COUNT(*) as count FROM users');
|
||||
return (result?.count ?? 0) > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if any local (non-OIDC) users exist
|
||||
* OIDC users have username starting with 'oidc:'
|
||||
*/
|
||||
existsLocal(): boolean {
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
"SELECT COUNT(*) as count FROM users WHERE username NOT LIKE 'oidc:%'"
|
||||
);
|
||||
return (result?.count ?? 0) > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
getById(id: number): User | undefined {
|
||||
return db.queryFirst<User>('SELECT * FROM users WHERE id = ?', id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user by username
|
||||
*/
|
||||
getByUsername(username: string): User | undefined {
|
||||
return db.queryFirst<User>('SELECT * FROM users WHERE username = ?', username);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all usernames (for login analysis - typo detection)
|
||||
* Excludes OIDC users since they can't login with password
|
||||
*/
|
||||
getAllUsernames(): string[] {
|
||||
const results = db.query<{ username: string }>(
|
||||
"SELECT username FROM users WHERE username NOT LIKE 'oidc:%'"
|
||||
);
|
||||
return results.map((r) => r.username);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user (should only be called once during setup)
|
||||
*/
|
||||
create(username: string, passwordHash: string): number {
|
||||
db.execute(
|
||||
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
|
||||
username,
|
||||
passwordHash
|
||||
);
|
||||
|
||||
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
|
||||
return result?.id ?? 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update user's password
|
||||
*/
|
||||
updatePassword(id: number, passwordHash: string): boolean {
|
||||
const affected = db.execute(
|
||||
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
passwordHash,
|
||||
id
|
||||
);
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update username
|
||||
*/
|
||||
updateUsername(id: number, username: string): boolean {
|
||||
const affected = db.execute(
|
||||
'UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
username,
|
||||
id
|
||||
);
|
||||
return affected > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get or create OIDC user
|
||||
* OIDC users have no password (placeholder hash)
|
||||
*/
|
||||
getOrCreateOidcUser(identifier: string): number {
|
||||
const username = `oidc:${identifier}`;
|
||||
const existing = this.getByUsername(username);
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
// Create with placeholder - OIDC users can't login with password
|
||||
db.execute(
|
||||
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
|
||||
username,
|
||||
'OIDC_NO_PASSWORD'
|
||||
);
|
||||
|
||||
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
|
||||
return result?.id ?? 0;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Profilarr Database Schema
|
||||
-- This file documents the current database schema after all migrations
|
||||
-- DO NOT execute this file directly - use migrations instead
|
||||
-- Last updated: 2026-01-22
|
||||
-- Last updated: 2026-01-24
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: migrations
|
||||
@@ -648,3 +648,57 @@ CREATE TABLE github_cache (
|
||||
-- GitHub cache indexes (Migration: 033_create_github_cache.ts)
|
||||
CREATE INDEX idx_github_cache_type ON github_cache(cache_type);
|
||||
CREATE INDEX idx_github_cache_expires ON github_cache(expires_at);
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: users
|
||||
-- Purpose: Store admin user credentials (single-user app)
|
||||
-- Migration: 036_create_auth_tables.ts
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: sessions
|
||||
-- Purpose: Store user sessions (allows login from multiple devices)
|
||||
-- Migration: 036_create_auth_tables.ts, 037_add_session_metadata.ts
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Session metadata (Migration 037)
|
||||
ip_address TEXT, -- Client IP when session created
|
||||
user_agent TEXT, -- Full user agent string
|
||||
browser TEXT, -- Parsed browser name/version (e.g., "Chrome 120")
|
||||
os TEXT, -- Parsed OS (e.g., "Windows 11")
|
||||
device_type TEXT, -- Device category (Desktop, Mobile, Tablet)
|
||||
last_active_at DATETIME, -- Updated on sliding expiration
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: auth_settings
|
||||
-- Purpose: Store auth configuration (singleton pattern with id=1)
|
||||
-- Migration: 036_create_auth_tables.ts
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE TABLE auth_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
session_duration_hours INTEGER NOT NULL DEFAULT 168, -- 7 days
|
||||
api_key TEXT, -- For programmatic access
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
207
src/lib/server/utils/auth/README.md
Normal file
207
src/lib/server/utils/auth/README.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Auth Module
|
||||
|
||||
Authentication and session management for Profilarr.
|
||||
|
||||
## Auth Modes
|
||||
|
||||
Controlled by the `AUTH` environment variable:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `on` | Default. Username/password required for all requests |
|
||||
| `local` | Skip auth for local IPs (192.168.x.x, 10.x.x.x, etc.) |
|
||||
| `off` | Disable auth entirely (trust reverse proxy like Authelia) |
|
||||
| `oidc` | Login via external OIDC provider (Google, Authentik, Keycloak) |
|
||||
|
||||
## Sequence Diagrams
|
||||
|
||||
### AUTH=on (Default)
|
||||
|
||||
#### First Run (No User)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as Profilarr
|
||||
|
||||
U->>P: GET /
|
||||
P->>P: Check: user exists? No
|
||||
P->>U: Redirect /auth/setup
|
||||
U->>P: GET /auth/setup
|
||||
P->>U: Show setup form
|
||||
U->>P: POST /auth/setup (username, password)
|
||||
P->>P: Create user, create session
|
||||
P->>U: Set cookie, redirect /
|
||||
```
|
||||
|
||||
#### Login Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as Profilarr
|
||||
|
||||
U->>P: GET /
|
||||
P->>P: Check: has session? No
|
||||
P->>U: Redirect /auth/login
|
||||
U->>P: GET /auth/login
|
||||
P->>U: Show login form
|
||||
U->>P: POST /auth/login (username, password)
|
||||
P->>P: Verify password, create session
|
||||
P->>U: Set cookie, redirect /
|
||||
```
|
||||
|
||||
#### Authenticated Request
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as Profilarr
|
||||
|
||||
U->>P: GET / (with session cookie)
|
||||
P->>P: Validate session
|
||||
P->>P: Past halfway? Extend session
|
||||
P->>U: 200 OK (page content)
|
||||
```
|
||||
|
||||
### AUTH=local
|
||||
|
||||
#### Local IP
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (192.168.x.x)
|
||||
participant P as Profilarr
|
||||
|
||||
U->>P: GET /
|
||||
P->>P: Check: AUTH=local + local IP? Yes
|
||||
P->>U: 200 OK (skip auth)
|
||||
```
|
||||
|
||||
#### External IP
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (external)
|
||||
participant P as Profilarr
|
||||
|
||||
U->>P: GET /
|
||||
P->>P: Check: AUTH=local + local IP? No
|
||||
P->>P: Check: has session? No
|
||||
P->>U: Redirect /auth/login
|
||||
Note over U,P: Same as AUTH=on login flow
|
||||
```
|
||||
|
||||
### AUTH=oidc
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as Profilarr
|
||||
participant IDP as OIDC Provider
|
||||
|
||||
U->>P: GET /
|
||||
P->>P: Check: has session? No
|
||||
P->>U: Redirect /auth/login
|
||||
U->>P: GET /auth/login
|
||||
P->>U: Show "Sign in with SSO" button
|
||||
U->>P: GET /auth/oidc/login
|
||||
P->>P: Generate state, store in cookie
|
||||
P->>U: Redirect to IDP
|
||||
U->>IDP: Login at provider
|
||||
IDP->>U: Redirect /auth/oidc/callback?code=xxx
|
||||
U->>P: GET /auth/oidc/callback
|
||||
P->>P: Verify state
|
||||
P->>IDP: Exchange code for tokens
|
||||
IDP->>P: access_token + id_token
|
||||
P->>P: Verify id_token, create session
|
||||
P->>U: Set cookie, redirect /
|
||||
```
|
||||
|
||||
### AUTH=off
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as Profilarr
|
||||
|
||||
U->>P: GET /
|
||||
P->>P: Check: AUTH=off? Yes
|
||||
P->>U: 200 OK (no auth checks)
|
||||
Note over U,P: Trust reverse proxy (Authelia, etc.)
|
||||
```
|
||||
|
||||
## Scenarios
|
||||
|
||||
### AUTH=on (Default)
|
||||
|
||||
| Scenario | What Happens |
|
||||
|----------|--------------|
|
||||
| First run, no users | Redirect to `/auth/setup` |
|
||||
| User exists, not logged in | Redirect to `/auth/login` |
|
||||
| User exists, logged in | Allow access |
|
||||
| Session expired | Redirect to `/auth/login` |
|
||||
| API request, no auth | 401 JSON response |
|
||||
| API request, valid X-Api-Key | Allow access |
|
||||
| Visit `/auth/setup` after user exists | Redirect to `/` |
|
||||
|
||||
### AUTH=local
|
||||
|
||||
| Scenario | What Happens |
|
||||
|----------|--------------|
|
||||
| Local IP (192.168.x.x) | Allow access (skip auth) |
|
||||
| Local IP, first run | Redirect to `/auth/setup` (still need to create user) |
|
||||
| External IP, not logged in | Redirect to `/auth/login` |
|
||||
| External IP, logged in | Allow access |
|
||||
|
||||
### AUTH=oidc
|
||||
|
||||
| Scenario | What Happens |
|
||||
|----------|--------------|
|
||||
| Not logged in | Redirect to `/auth/login` (shows SSO button) |
|
||||
| Click "Sign in with SSO" | Redirect to OIDC provider |
|
||||
| Return from provider | Create session, redirect to `/` |
|
||||
| Session expired | Redirect to `/auth/login` |
|
||||
| Visit `/auth/setup` | Redirect to `/` (no setup needed) |
|
||||
|
||||
### AUTH=off
|
||||
|
||||
| Scenario | What Happens |
|
||||
|----------|--------------|
|
||||
| Any request | Allow access (no auth checks) |
|
||||
|
||||
## Session Management
|
||||
|
||||
- **Duration**: 7 days (configurable in Settings > Security)
|
||||
- **Sliding expiration**: Session extended when past halfway point
|
||||
- **Multiple sessions**: Users can be logged in from multiple devices
|
||||
- **Metadata tracked**: IP, user agent, browser, OS, device type, last active
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `middleware.ts` | Core auth logic (getAuthState, isPublicPath) |
|
||||
| `password.ts` | Bcrypt hash/verify |
|
||||
| `network.ts` | IP detection (getClientIp, isLocalAddress) |
|
||||
| `userAgent.ts` | Parse browser/OS/device from user agent |
|
||||
| `apiKey.ts` | API key generation |
|
||||
| `oidc.ts` | OIDC discovery, token exchange, JWT parsing |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Auth mode
|
||||
AUTH=on # on, local, off, oidc
|
||||
|
||||
# OIDC (only when AUTH=oidc)
|
||||
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=profilarr
|
||||
OIDC_CLIENT_SECRET=your-secret
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/auth/setup` | First-run setup (create admin account) |
|
||||
| `/auth/login` | Login page (form or OIDC button) |
|
||||
| `/auth/logout` | Logout (clear session) |
|
||||
| `/auth/oidc/login` | Initiate OIDC flow |
|
||||
| `/auth/oidc/callback` | Handle OIDC provider response |
|
||||
| `/settings/security` | Manage sessions, API key, password |
|
||||
12
src/lib/server/utils/auth/apiKey.ts
Normal file
12
src/lib/server/utils/auth/apiKey.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* API key generation utility
|
||||
* Generates 32 hex character keys (UUID without hyphens, like Sonarr)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
* Returns 32 lowercase hex characters (128 bits)
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return crypto.randomUUID().replace(/-/g, '');
|
||||
}
|
||||
126
src/lib/server/utils/auth/loginAnalysis.ts
Normal file
126
src/lib/server/utils/auth/loginAnalysis.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Login attempt analysis utilities
|
||||
* Helps distinguish between typos and potential attack attempts
|
||||
*/
|
||||
|
||||
// Common usernames attackers try
|
||||
const COMMON_ATTACK_USERNAMES = [
|
||||
'admin',
|
||||
'administrator',
|
||||
'root',
|
||||
'user',
|
||||
'test',
|
||||
'guest',
|
||||
'demo',
|
||||
'system',
|
||||
'operator',
|
||||
'superuser',
|
||||
'master',
|
||||
'default'
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a username is commonly used in brute force attacks
|
||||
*/
|
||||
export function isCommonAttackUsername(username: string): boolean {
|
||||
return COMMON_ATTACK_USERNAMES.includes(username.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = [];
|
||||
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j - 1] + 1, // substitution
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j] + 1 // deletion
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length][a.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a similar username from the list of existing usernames
|
||||
* Returns the similar username if found (within 2 edits), null otherwise
|
||||
*/
|
||||
export function findSimilarUsername(
|
||||
attempted: string,
|
||||
existingUsernames: string[]
|
||||
): string | null {
|
||||
const attemptedLower = attempted.toLowerCase();
|
||||
|
||||
for (const existing of existingUsernames) {
|
||||
const distance = levenshteinDistance(attemptedLower, existing.toLowerCase());
|
||||
// Allow up to 2 character differences for typo detection
|
||||
if (distance > 0 && distance <= 2) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LoginFailureAnalysis {
|
||||
reason: 'user_not_found' | 'invalid_password';
|
||||
similarUser: string | null;
|
||||
isCommonAttack: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a failed login attempt for logging purposes
|
||||
*/
|
||||
export function analyzeLoginFailure(
|
||||
username: string,
|
||||
existingUsernames: string[],
|
||||
userExists: boolean
|
||||
): LoginFailureAnalysis {
|
||||
if (userExists) {
|
||||
return {
|
||||
reason: 'invalid_password',
|
||||
similarUser: null,
|
||||
isCommonAttack: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reason: 'user_not_found',
|
||||
similarUser: findSimilarUsername(username, existingUsernames),
|
||||
isCommonAttack: isCommonAttackUsername(username)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a login failure for logging
|
||||
*/
|
||||
export function formatLoginFailure(analysis: LoginFailureAnalysis): string {
|
||||
if (analysis.reason === 'invalid_password') {
|
||||
return 'invalid password';
|
||||
}
|
||||
|
||||
if (analysis.similarUser) {
|
||||
return `unknown user (similar to '${analysis.similarUser}')`;
|
||||
}
|
||||
|
||||
if (analysis.isCommonAttack) {
|
||||
return 'unknown user (common attack username)';
|
||||
}
|
||||
|
||||
return 'unknown user';
|
||||
}
|
||||
152
src/lib/server/utils/auth/middleware.ts
Normal file
152
src/lib/server/utils/auth/middleware.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Auth middleware utilities
|
||||
* Core auth logic - keeps hooks.server.ts thin
|
||||
*/
|
||||
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { config } from '$config';
|
||||
import { usersQueries, type User } from '$db/queries/users.ts';
|
||||
import { sessionsQueries, type Session } from '$db/queries/sessions.ts';
|
||||
import { authSettingsQueries } from '$db/queries/authSettings.ts';
|
||||
import { isLocalAddress, getClientIp } from './network.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
/**
|
||||
* Auth state returned by getAuthState
|
||||
*/
|
||||
export interface AuthState {
|
||||
needsSetup: boolean;
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
skipAuth: boolean; // true when AUTH=off or AUTH=local+local IP
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that don't require authentication
|
||||
*/
|
||||
const PUBLIC_PATHS = ['/auth/login', '/auth/setup', '/auth/oidc', '/api/v1/health'];
|
||||
|
||||
/**
|
||||
* Check if a path is public (doesn't require auth)
|
||||
*/
|
||||
export function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth state from request
|
||||
* Checks auth mode, API key, and session cookie
|
||||
*/
|
||||
export function getAuthState(event: RequestEvent): AuthState {
|
||||
const hasLocalUsers = usersQueries.existsLocal();
|
||||
|
||||
// AUTH=off - skip all auth (trust external proxy like Authelia/Authentik)
|
||||
if (config.authMode === 'off') {
|
||||
return {
|
||||
needsSetup: false,
|
||||
user: null,
|
||||
session: null,
|
||||
skipAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
// AUTH=local - skip auth for local IPs
|
||||
if (config.authMode === 'local') {
|
||||
const clientIp = getClientIp(event);
|
||||
if (isLocalAddress(clientIp)) {
|
||||
void logger.debug('Local IP bypass', {
|
||||
source: 'Auth',
|
||||
meta: { ip: clientIp }
|
||||
});
|
||||
return {
|
||||
needsSetup: !hasLocalUsers,
|
||||
user: null,
|
||||
session: null,
|
||||
skipAuth: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH=oidc - uses sessions but no local user/password
|
||||
if (config.authMode === 'oidc') {
|
||||
const sessionId = event.cookies.get('session');
|
||||
const session = sessionId ? sessionsQueries.getValidById(sessionId) ?? null : null;
|
||||
const user = session ? usersQueries.getById(session.user_id) ?? null : null;
|
||||
|
||||
return {
|
||||
needsSetup: false, // No setup needed for OIDC
|
||||
user,
|
||||
session,
|
||||
skipAuth: false
|
||||
};
|
||||
}
|
||||
|
||||
// AUTH=on (default) - full username/password auth
|
||||
|
||||
// Check API key (header or query param)
|
||||
const apiKey =
|
||||
event.request.headers.get('X-Api-Key') || event.url.searchParams.get('apikey');
|
||||
if (apiKey) {
|
||||
const ip = getClientIp(event);
|
||||
const endpoint = event.url.pathname;
|
||||
|
||||
if (authSettingsQueries.validateApiKey(apiKey)) {
|
||||
void logger.info('API key authenticated', {
|
||||
source: 'Auth:APIKey',
|
||||
meta: { ip, endpoint }
|
||||
});
|
||||
return {
|
||||
needsSetup: false,
|
||||
user: { id: 0, username: 'api' } as User,
|
||||
session: null,
|
||||
skipAuth: false
|
||||
};
|
||||
} else {
|
||||
// Mask API key - only show last 4 chars
|
||||
const maskedKey = apiKey.length > 4 ? `****${apiKey.slice(-4)}` : '****';
|
||||
void logger.warn('Invalid API key', {
|
||||
source: 'Auth:APIKey',
|
||||
meta: { ip, endpoint, key: maskedKey }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check session cookie
|
||||
const sessionId = event.cookies.get('session');
|
||||
const session = sessionId ? sessionsQueries.getValidById(sessionId) ?? null : null;
|
||||
const user = session ? usersQueries.getById(session.user_id) ?? null : null;
|
||||
|
||||
return {
|
||||
needsSetup: !hasLocalUsers,
|
||||
user,
|
||||
session,
|
||||
skipAuth: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding expiration: extend session if past halfway point
|
||||
* Avoids DB write on every request while keeping active users logged in
|
||||
*/
|
||||
export function maybeExtendSession(session: Session): void {
|
||||
const durationHours = authSettingsQueries.getSessionDurationHours();
|
||||
const expiresAt = new Date(session.expires_at).getTime();
|
||||
const now = Date.now();
|
||||
const halfDuration = (durationHours * 60 * 60 * 1000) / 2;
|
||||
|
||||
// Only extend if less than half the duration remains
|
||||
if (expiresAt - now < halfDuration) {
|
||||
sessionsQueries.extendExpiration(session.id, durationHours);
|
||||
void logger.debug('Session extended', {
|
||||
source: 'Auth:Session',
|
||||
meta: { userId: session.user_id }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired sessions - call on startup
|
||||
*/
|
||||
export function cleanupExpiredSessions(): number {
|
||||
return sessionsQueries.deleteExpired();
|
||||
}
|
||||
136
src/lib/server/utils/auth/network.ts
Normal file
136
src/lib/server/utils/auth/network.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Network utilities for detecting local/private IP addresses
|
||||
* Used for AUTH=local mode to bypass auth for local network requests
|
||||
*
|
||||
* Based on Sonarr's implementation:
|
||||
* https://github.com/Sonarr/Sonarr/blob/develop/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if an IP address is a local/private network address
|
||||
*
|
||||
* IPv4 ranges:
|
||||
* - 127.0.0.0/8 (loopback)
|
||||
* - 10.0.0.0/8 (Class A private)
|
||||
* - 172.16.0.0/12 (Class B private)
|
||||
* - 192.168.0.0/16 (Class C private)
|
||||
* - 169.254.0.0/16 (link-local, no DHCP)
|
||||
*
|
||||
* IPv6 ranges:
|
||||
* - ::1 (loopback)
|
||||
* - fe80::/10 (link-local)
|
||||
* - fc00::/7 (unique local)
|
||||
* - fec0::/10 (site-local, deprecated but still checked)
|
||||
*/
|
||||
export function isLocalAddress(ip: string): boolean {
|
||||
// Handle IPv6-mapped IPv4 (::ffff:192.168.1.1)
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7);
|
||||
}
|
||||
|
||||
// Check if it's an IPv4 address
|
||||
if (ip.includes('.')) {
|
||||
return isLocalIPv4(ip);
|
||||
}
|
||||
|
||||
// IPv6 checks
|
||||
return isLocalIPv6(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv4 address is local/private
|
||||
*/
|
||||
function isLocalIPv4(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
const bytes = parts.map((p) => parseInt(p, 10));
|
||||
if (bytes.some((b) => isNaN(b) || b < 0 || b > 255)) return false;
|
||||
|
||||
const [a, b] = bytes;
|
||||
|
||||
// Loopback: 127.0.0.0/8
|
||||
if (a === 127) return true;
|
||||
|
||||
// Class A private: 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
|
||||
// Class B private: 172.16.0.0/12 (172.16.x.x - 172.31.x.x)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
|
||||
// Class C private: 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
|
||||
// Link-local: 169.254.0.0/16 (no DHCP assigned)
|
||||
if (a === 169 && b === 254) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv6 address is local
|
||||
*/
|
||||
function isLocalIPv6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
// Loopback
|
||||
if (lower === '::1') return true;
|
||||
|
||||
// Link-local: fe80::/10
|
||||
if (lower.startsWith('fe80:')) return true;
|
||||
|
||||
// Unique local: fc00::/7 (fc00:: or fd00::)
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
||||
|
||||
// Site-local (deprecated): fec0::/10
|
||||
if (lower.startsWith('fec')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to check for client IP, in order of precedence
|
||||
* Based on @supercharge/request-ip (used by Overseerr)
|
||||
*/
|
||||
const IP_HEADERS = [
|
||||
'x-forwarded-for', // Standard proxy header (may contain multiple IPs)
|
||||
'x-real-ip', // Nginx
|
||||
'x-client-ip', // Apache
|
||||
'cf-connecting-ip', // Cloudflare
|
||||
'fastly-client-ip', // Fastly
|
||||
'true-client-ip', // Akamai/Cloudflare
|
||||
'x-cluster-client-ip' // Rackspace
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract client IP from request
|
||||
*
|
||||
* Checks common proxy headers in order (like Overseerr's approach),
|
||||
* then falls back to SvelteKit's getClientAddress()
|
||||
*/
|
||||
export function getClientIp(event: { getClientAddress: () => string; request: Request }): string {
|
||||
const headers = event.request.headers;
|
||||
|
||||
// Check proxy headers in order
|
||||
for (const header of IP_HEADERS) {
|
||||
const value = headers.get(header);
|
||||
if (value) {
|
||||
// x-forwarded-for may contain multiple IPs: "client, proxy1, proxy2"
|
||||
const ip = value.split(',')[0].trim();
|
||||
if (ip) return ip;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to SvelteKit's built-in
|
||||
try {
|
||||
const address = event.getClientAddress();
|
||||
if (address && address !== 'unknown') {
|
||||
return address;
|
||||
}
|
||||
} catch {
|
||||
// Can throw during prerendering
|
||||
}
|
||||
|
||||
// Default to loopback
|
||||
return '127.0.0.1';
|
||||
}
|
||||
194
src/lib/server/utils/auth/oidc.ts
Normal file
194
src/lib/server/utils/auth/oidc.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* OIDC (OpenID Connect) utilities
|
||||
* Handles discovery, token exchange, and ID token parsing
|
||||
*
|
||||
* No external dependencies - just native fetch and crypto
|
||||
*/
|
||||
|
||||
export interface DiscoveryDocument {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export interface IdTokenClaims {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
iss: string;
|
||||
aud: string | string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
// Cache discovery document (doesn't change often)
|
||||
let cachedDiscovery: {
|
||||
url: string;
|
||||
doc: DiscoveryDocument;
|
||||
expires: number;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* Fetch and cache OIDC discovery document
|
||||
*/
|
||||
export async function getDiscoveryDocument(url: string): Promise<DiscoveryDocument> {
|
||||
if (cachedDiscovery && cachedDiscovery.url === url && Date.now() < cachedDiscovery.expires) {
|
||||
return cachedDiscovery.doc;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);
|
||||
}
|
||||
|
||||
const doc = (await response.json()) as DiscoveryDocument;
|
||||
|
||||
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
||||
throw new Error('Invalid OIDC discovery document');
|
||||
}
|
||||
|
||||
// Cache for 1 hour
|
||||
cachedDiscovery = {
|
||||
url,
|
||||
doc,
|
||||
expires: Date.now() + 60 * 60 * 1000
|
||||
};
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random state token for CSRF protection
|
||||
*/
|
||||
export function generateState(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the authorization URL
|
||||
*/
|
||||
export function buildAuthorizationUrl(
|
||||
authorizationEndpoint: string,
|
||||
opts: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
scope?: string;
|
||||
}
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: opts.clientId,
|
||||
redirect_uri: opts.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: opts.scope || 'openid email profile',
|
||||
state: opts.state
|
||||
});
|
||||
|
||||
return `${authorizationEndpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
tokenEndpoint: string,
|
||||
code: string,
|
||||
opts: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: opts.clientId,
|
||||
client_secret: opts.clientSecret,
|
||||
redirect_uri: opts.redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`Token exchange error: ${data.error}`);
|
||||
}
|
||||
|
||||
return data as TokenResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT and extract claims (no signature verification)
|
||||
*
|
||||
* Note: We trust the token because it came from a server-to-server
|
||||
* exchange using our client secret. The provider validated everything.
|
||||
*/
|
||||
export function decodeIdToken(idToken: string): IdTokenClaims {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid JWT format');
|
||||
}
|
||||
|
||||
// Base64URL decode the payload
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = atob(payload);
|
||||
const claims = JSON.parse(decoded) as IdTokenClaims;
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify basic claims on the ID token
|
||||
*/
|
||||
export function verifyIdToken(
|
||||
claims: IdTokenClaims,
|
||||
opts: {
|
||||
clientId: string;
|
||||
issuer: string;
|
||||
}
|
||||
): void {
|
||||
// Verify issuer
|
||||
if (claims.iss !== opts.issuer) {
|
||||
throw new Error(`Invalid issuer: expected ${opts.issuer}, got ${claims.iss}`);
|
||||
}
|
||||
|
||||
// Verify audience
|
||||
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
||||
if (!audiences.includes(opts.clientId)) {
|
||||
throw new Error(`Invalid audience: token not issued for ${opts.clientId}`);
|
||||
}
|
||||
|
||||
// Verify expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp && claims.exp < now) {
|
||||
throw new Error('ID token has expired');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached discovery document
|
||||
*/
|
||||
export function clearDiscoveryCache(): void {
|
||||
cachedDiscovery = null;
|
||||
}
|
||||
20
src/lib/server/utils/auth/password.ts
Normal file
20
src/lib/server/utils/auth/password.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Password hashing utilities using bcrypt via @felix/bcrypt
|
||||
* Uses Rust bcrypt via Deno FFI
|
||||
*/
|
||||
|
||||
import { hash, verify } from '@felix/bcrypt';
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
*/
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return hash(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a stored hash
|
||||
*/
|
||||
export function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
||||
return verify(password, storedHash);
|
||||
}
|
||||
151
src/lib/server/utils/auth/userAgent.ts
Normal file
151
src/lib/server/utils/auth/userAgent.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* User Agent Parser
|
||||
*
|
||||
* Simple regex-based parser to extract browser, OS, and device type
|
||||
* from user agent strings. No heavy libraries - just pattern matching.
|
||||
*/
|
||||
|
||||
export interface ParsedUserAgent {
|
||||
browser: string; // "Chrome 120", "Firefox 121", "Safari 17"
|
||||
os: string; // "Windows 11", "macOS 14", "Ubuntu", "iOS 17"
|
||||
deviceType: string; // "Desktop", "Mobile", "Tablet"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user agent string into structured data
|
||||
*/
|
||||
export function parseUserAgent(ua: string): ParsedUserAgent {
|
||||
if (!ua) {
|
||||
return { browser: 'Unknown', os: 'Unknown', deviceType: 'Unknown' };
|
||||
}
|
||||
|
||||
return {
|
||||
browser: parseBrowser(ua),
|
||||
os: parseOS(ua),
|
||||
deviceType: parseDeviceType(ua)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract browser name and version
|
||||
*/
|
||||
function parseBrowser(ua: string): string {
|
||||
// Order matters - check more specific patterns first
|
||||
|
||||
// Edge (Chromium-based)
|
||||
const edge = ua.match(/Edg(?:e|A|iOS)?\/(\d+)/);
|
||||
if (edge) return `Edge ${edge[1]}`;
|
||||
|
||||
// Opera (also Chromium-based, check before Chrome)
|
||||
const opera = ua.match(/(?:OPR|Opera)\/(\d+)/);
|
||||
if (opera) return `Opera ${opera[1]}`;
|
||||
|
||||
// Firefox
|
||||
const firefox = ua.match(/Firefox\/(\d+)/);
|
||||
if (firefox) return `Firefox ${firefox[1]}`;
|
||||
|
||||
// Safari (check before Chrome since Chrome includes Safari in UA)
|
||||
// Safari doesn't include "Chrome" in its UA
|
||||
if (ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium')) {
|
||||
const safari = ua.match(/Version\/(\d+)/);
|
||||
if (safari) return `Safari ${safari[1]}`;
|
||||
return 'Safari';
|
||||
}
|
||||
|
||||
// Chrome (and Chromium-based browsers not caught above)
|
||||
const chrome = ua.match(/(?:Chrome|Chromium)\/(\d+)/);
|
||||
if (chrome) return `Chrome ${chrome[1]}`;
|
||||
|
||||
// Internet Explorer
|
||||
const ie = ua.match(/(?:MSIE |rv:)(\d+)/);
|
||||
if (ie) return `IE ${ie[1]}`;
|
||||
|
||||
// Fallback: try to find any browser-like pattern
|
||||
const generic = ua.match(/(\w+)\/(\d+)/);
|
||||
if (generic) return `${generic[1]} ${generic[2]}`;
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract operating system name and version
|
||||
*/
|
||||
function parseOS(ua: string): string {
|
||||
// iOS (check before Mac since iOS includes "like Mac OS X")
|
||||
const ios = ua.match(/(?:iPhone|iPad|iPod).*?OS (\d+)/);
|
||||
if (ios) return `iOS ${ios[1]}`;
|
||||
|
||||
// Android
|
||||
const android = ua.match(/Android (\d+(?:\.\d+)?)/);
|
||||
if (android) return `Android ${android[1]}`;
|
||||
|
||||
// Windows
|
||||
// Note: Windows 11 still reports "Windows NT 10.0" for backwards compatibility
|
||||
// There's no reliable way to distinguish Win10 from Win11 via user agent alone
|
||||
if (ua.includes('Windows')) {
|
||||
if (ua.includes('Windows NT 10.0')) return 'Windows';
|
||||
if (ua.includes('Windows NT 6.3')) return 'Windows 8.1';
|
||||
if (ua.includes('Windows NT 6.2')) return 'Windows 8';
|
||||
if (ua.includes('Windows NT 6.1')) return 'Windows 7';
|
||||
if (ua.includes('Windows NT 6.0')) return 'Windows Vista';
|
||||
if (ua.includes('Windows NT 5.1')) return 'Windows XP';
|
||||
return 'Windows';
|
||||
}
|
||||
|
||||
// macOS (after iOS check)
|
||||
const mac = ua.match(/Mac OS X (\d+)[_.](\d+)/);
|
||||
if (mac) {
|
||||
const major = parseInt(mac[1]);
|
||||
const minor = parseInt(mac[2]);
|
||||
// macOS 11+ uses major version only in marketing
|
||||
if (major >= 11) return `macOS ${major}`;
|
||||
// macOS 10.x uses 10.minor naming
|
||||
return `macOS ${major}.${minor}`;
|
||||
}
|
||||
if (ua.includes('Macintosh')) return 'macOS';
|
||||
|
||||
// Linux distributions
|
||||
if (ua.includes('Ubuntu')) return 'Ubuntu';
|
||||
if (ua.includes('Fedora')) return 'Fedora';
|
||||
if (ua.includes('Debian')) return 'Debian';
|
||||
if (ua.includes('Arch')) return 'Arch Linux';
|
||||
if (ua.includes('CrOS')) return 'Chrome OS';
|
||||
if (ua.includes('Linux')) return 'Linux';
|
||||
|
||||
// BSD variants
|
||||
if (ua.includes('FreeBSD')) return 'FreeBSD';
|
||||
if (ua.includes('OpenBSD')) return 'OpenBSD';
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine device type from user agent
|
||||
*/
|
||||
function parseDeviceType(ua: string): string {
|
||||
// Tablets (check before mobile since some tablets include "Mobile")
|
||||
if (
|
||||
ua.includes('iPad') ||
|
||||
ua.includes('Tablet') ||
|
||||
(ua.includes('Android') && !ua.includes('Mobile'))
|
||||
) {
|
||||
return 'Tablet';
|
||||
}
|
||||
|
||||
// Mobile devices
|
||||
if (
|
||||
ua.includes('Mobile') ||
|
||||
ua.includes('iPhone') ||
|
||||
ua.includes('iPod') ||
|
||||
ua.includes('Android') ||
|
||||
ua.includes('webOS') ||
|
||||
ua.includes('BlackBerry') ||
|
||||
ua.includes('Opera Mini') ||
|
||||
ua.includes('IEMobile')
|
||||
) {
|
||||
return 'Mobile';
|
||||
}
|
||||
|
||||
// Default to Desktop
|
||||
return 'Desktop';
|
||||
}
|
||||
@@ -2,12 +2,20 @@
|
||||
* Application configuration singleton
|
||||
*/
|
||||
|
||||
export type AuthMode = 'on' | 'local' | 'off' | 'oidc';
|
||||
|
||||
class Config {
|
||||
private basePath: string;
|
||||
public readonly timezone: string;
|
||||
public readonly parserUrl: string;
|
||||
public readonly port: number;
|
||||
public readonly host: string;
|
||||
public readonly authMode: AuthMode;
|
||||
public readonly oidc: {
|
||||
discoveryUrl: string | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// Default base path logic:
|
||||
@@ -36,6 +44,19 @@ class Config {
|
||||
// Server bind configuration
|
||||
this.port = parseInt(Deno.env.get('PORT') || '6868', 10);
|
||||
this.host = Deno.env.get('HOST') || '0.0.0.0';
|
||||
|
||||
// Auth mode: 'on' (default), 'local', 'off', 'oidc'
|
||||
const auth = (Deno.env.get('AUTH') || 'on').toLowerCase();
|
||||
this.authMode = ['on', 'local', 'off', 'oidc'].includes(auth)
|
||||
? (auth as AuthMode)
|
||||
: 'on';
|
||||
|
||||
// OIDC configuration (only used when AUTH=oidc)
|
||||
this.oidc = {
|
||||
discoveryUrl: Deno.env.get('OIDC_DISCOVERY_URL') || null,
|
||||
clientId: Deno.env.get('OIDC_CLIENT_ID') || null,
|
||||
clientSecret: Deno.env.get('OIDC_CLIENT_SECRET') || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
import PageNav from '$ui/navigation/pageNav/pageNav.svelte';
|
||||
import AlertContainer from '$alerts/AlertContainer.svelte';
|
||||
import { sidebarCollapsed } from '$lib/client/stores/sidebar';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let data;
|
||||
|
||||
// Hide navigation on auth pages (login, setup, etc.)
|
||||
$: isAuthPage = $page.url.pathname.startsWith('/auth/');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -14,25 +18,29 @@
|
||||
<title>Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<Navbar collapsed={$sidebarCollapsed} />
|
||||
<PageNav collapsed={$sidebarCollapsed} version={data.version} />
|
||||
{#if !isAuthPage}
|
||||
<Navbar collapsed={$sidebarCollapsed} />
|
||||
<PageNav collapsed={$sidebarCollapsed} version={data.version} />
|
||||
{/if}
|
||||
<AlertContainer />
|
||||
|
||||
<!-- Sidebar collapse toggle button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => sidebarCollapsed.toggle()}
|
||||
class="fixed top-16 z-50 flex h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
style="left: {$sidebarCollapsed ? '24px' : '288px'}"
|
||||
aria-label={$sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
</div>
|
||||
</button>
|
||||
{#if !isAuthPage}
|
||||
<!-- Sidebar collapse toggle button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => sidebarCollapsed.toggle()}
|
||||
class="fixed top-16 z-50 flex h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
style="left: {$sidebarCollapsed ? '24px' : '288px'}"
|
||||
aria-label={$sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<main class="transition-all duration-200 {$sidebarCollapsed ? 'pl-[24px]' : 'pl-72'}">
|
||||
<main class={isAuthPage ? '' : `transition-all duration-200 ${$sidebarCollapsed ? 'pl-[24px]' : 'pl-72'}`}>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
name="value-{childIndex}"
|
||||
value={child.value as number}
|
||||
on:change={(e) => {
|
||||
child.value = e.detail;
|
||||
if (e.detail !== undefined) child.value = e.detail;
|
||||
notifyChange();
|
||||
}}
|
||||
font="mono"
|
||||
@@ -181,7 +181,7 @@
|
||||
name="value-{childIndex}"
|
||||
value={child.value as number}
|
||||
on:change={(e) => {
|
||||
child.value = e.detail;
|
||||
if (e.detail !== undefined) child.value = e.detail;
|
||||
notifyChange();
|
||||
}}
|
||||
min={1}
|
||||
|
||||
99
src/routes/auth/login/+page.server.ts
Normal file
99
src/routes/auth/login/+page.server.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { config } from '$config';
|
||||
import { usersQueries } from '$db/queries/users.ts';
|
||||
import { sessionsQueries } from '$db/queries/sessions.ts';
|
||||
import { authSettingsQueries } from '$db/queries/authSettings.ts';
|
||||
import { verifyPassword } from '$auth/password.ts';
|
||||
import { getClientIp } from '$auth/network.ts';
|
||||
import { parseUserAgent } from '$auth/userAgent.ts';
|
||||
import { analyzeLoginFailure, formatLoginFailure } from '$auth/loginAnalysis.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = () => {
|
||||
// OIDC mode - just show the OIDC button, no setup needed
|
||||
if (config.authMode === 'oidc') {
|
||||
return { authMode: 'oidc' };
|
||||
}
|
||||
|
||||
// If no local users exist, redirect to setup
|
||||
// (OIDC users don't count - they can't login with password)
|
||||
if (!usersQueries.existsLocal()) {
|
||||
throw redirect(303, '/auth/setup');
|
||||
}
|
||||
|
||||
return { authMode: config.authMode };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const formData = await request.formData();
|
||||
const username = (formData.get('username') as string)?.trim();
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
// Validation
|
||||
if (!username || !password) {
|
||||
return fail(400, { error: 'Username and password are required', username });
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = usersQueries.getByUsername(username);
|
||||
if (!user) {
|
||||
const ip = getClientIp(event);
|
||||
const allUsernames = usersQueries.getAllUsernames();
|
||||
const analysis = analyzeLoginFailure(username, allUsernames, false);
|
||||
|
||||
await logger.warn(`Login failed for '${username}': ${formatLoginFailure(analysis)}`, {
|
||||
source: 'Auth:Login',
|
||||
meta: { username, ip, ...analysis }
|
||||
});
|
||||
return fail(400, { error: 'Invalid username or password', username });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const valid = await verifyPassword(password, user.password_hash);
|
||||
if (!valid) {
|
||||
const ip = getClientIp(event);
|
||||
const analysis = analyzeLoginFailure(username, [], true);
|
||||
|
||||
await logger.warn(`Login failed for '${username}': ${formatLoginFailure(analysis)}`, {
|
||||
source: 'Auth:Login',
|
||||
meta: { username, ip, ...analysis }
|
||||
});
|
||||
return fail(400, { error: 'Invalid username or password', username });
|
||||
}
|
||||
|
||||
// Capture session metadata
|
||||
const ipAddress = getClientIp(event);
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const parsed = parseUserAgent(userAgent);
|
||||
|
||||
// Create session with metadata
|
||||
const durationHours = authSettingsQueries.getSessionDurationHours();
|
||||
const sessionId = sessionsQueries.create(user.id, durationHours, {
|
||||
ipAddress,
|
||||
userAgent,
|
||||
browser: parsed.browser,
|
||||
os: parsed.os,
|
||||
deviceType: parsed.deviceType
|
||||
});
|
||||
|
||||
await logger.info(`Login successful for '${username}'`, {
|
||||
source: 'Auth:Login',
|
||||
meta: { username, ip: ipAddress, browser: parsed.browser, device: parsed.deviceType }
|
||||
});
|
||||
|
||||
// Set session cookie
|
||||
const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000);
|
||||
cookies.set('session', sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires
|
||||
});
|
||||
|
||||
// Redirect to home
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
};
|
||||
93
src/routes/auth/login/+page.svelte
Normal file
93
src/routes/auth/login/+page.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
import { LogIn, KeyRound } from 'lucide-svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import FormInput from '$ui/form/FormInput.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import logo from '$assets/logo-512.png';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
|
||||
let submitting = false;
|
||||
let username = form?.username ?? '';
|
||||
let password = '';
|
||||
|
||||
// Show errors via alert system
|
||||
$: if (form?.error) {
|
||||
alertStore.add('error', form.error);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-neutral-100 dark:bg-neutral-900">
|
||||
<div class="w-full max-w-sm p-8">
|
||||
<div class="mb-8 flex items-center gap-4">
|
||||
<img src={logo} alt="Profilarr logo" class="h-12 w-12" />
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-neutral-900 dark:text-neutral-50">Welcome back</h1>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Sign in to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.authMode === 'oidc'}
|
||||
<!-- OIDC login button -->
|
||||
<Button
|
||||
href="/auth/oidc/login"
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
icon={KeyRound}
|
||||
text="Sign in with SSO"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Username/password form -->
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-6"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<FormInput
|
||||
name="username"
|
||||
label="Username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
bind:value={username}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
private_
|
||||
bind:value={password}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
icon={LogIn}
|
||||
text={submitting ? 'Signing In...' : 'Sign In'}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
26
src/routes/auth/logout/+server.ts
Normal file
26
src/routes/auth/logout/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { sessionsQueries } from '$db/queries/sessions.ts';
|
||||
import { usersQueries } from '$db/queries/users.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const sessionId = cookies.get('session');
|
||||
|
||||
if (sessionId) {
|
||||
// Get session info before deleting for logging
|
||||
const session = sessionsQueries.getById(sessionId);
|
||||
if (session) {
|
||||
const user = usersQueries.getById(session.user_id);
|
||||
await logger.info(`User '${user?.username ?? 'unknown'}' logged out`, {
|
||||
source: 'Auth:Session',
|
||||
meta: { userId: session.user_id, username: user?.username }
|
||||
});
|
||||
}
|
||||
sessionsQueries.deleteById(sessionId);
|
||||
}
|
||||
|
||||
cookies.delete('session', { path: '/' });
|
||||
|
||||
throw redirect(303, '/auth/login');
|
||||
};
|
||||
118
src/routes/auth/oidc/callback/+server.ts
Normal file
118
src/routes/auth/oidc/callback/+server.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { config } from '$config';
|
||||
import { getDiscoveryDocument, exchangeCode, decodeIdToken, verifyIdToken } from '$auth/oidc.ts';
|
||||
import { usersQueries } from '$db/queries/users.ts';
|
||||
import { sessionsQueries } from '$db/queries/sessions.ts';
|
||||
import { authSettingsQueries } from '$db/queries/authSettings.ts';
|
||||
import { getClientIp } from '$auth/network.ts';
|
||||
import { parseUserAgent } from '$auth/userAgent.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { url, cookies, request } = event;
|
||||
|
||||
// Get code and state from query params
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Handle provider errors
|
||||
if (errorParam) {
|
||||
throw error(400, `OIDC error: ${errorParam} - ${errorDescription || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Verify state (CSRF protection)
|
||||
const savedState = cookies.get('oidc_state');
|
||||
if (!state || state !== savedState) {
|
||||
const ip = getClientIp(event);
|
||||
await logger.warn('OIDC state mismatch (possible CSRF attempt)', {
|
||||
source: 'Auth:OIDC',
|
||||
meta: { ip }
|
||||
});
|
||||
throw error(400, 'Invalid state parameter');
|
||||
}
|
||||
cookies.delete('oidc_state', { path: '/' });
|
||||
|
||||
// Verify we have a code
|
||||
if (!code) {
|
||||
throw error(400, 'No authorization code provided');
|
||||
}
|
||||
|
||||
// Validate OIDC configuration
|
||||
if (!config.oidc.discoveryUrl || !config.oidc.clientId || !config.oidc.clientSecret) {
|
||||
throw error(500, 'OIDC is not configured');
|
||||
}
|
||||
|
||||
// Fetch discovery document
|
||||
const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl);
|
||||
|
||||
// Exchange code for tokens
|
||||
const ip = getClientIp(event);
|
||||
let tokens;
|
||||
try {
|
||||
tokens = await exchangeCode(discovery.token_endpoint, code, {
|
||||
clientId: config.oidc.clientId,
|
||||
clientSecret: config.oidc.clientSecret,
|
||||
redirectUri: `${config.serverUrl}/auth/oidc/callback`
|
||||
});
|
||||
} catch (err) {
|
||||
await logger.warn('OIDC token exchange failed', {
|
||||
source: 'Auth:OIDC',
|
||||
meta: { ip, error: err instanceof Error ? err.message : String(err) }
|
||||
});
|
||||
throw error(500, 'Failed to exchange authorization code');
|
||||
}
|
||||
|
||||
// Decode and verify ID token
|
||||
let claims;
|
||||
try {
|
||||
claims = decodeIdToken(tokens.id_token);
|
||||
verifyIdToken(claims, {
|
||||
clientId: config.oidc.clientId,
|
||||
issuer: discovery.issuer
|
||||
});
|
||||
} catch (err) {
|
||||
await logger.error('OIDC ID token verification failed', {
|
||||
source: 'Auth:OIDC',
|
||||
meta: { ip, error: err instanceof Error ? err.message : String(err) }
|
||||
});
|
||||
throw error(500, 'Failed to verify ID token');
|
||||
}
|
||||
|
||||
// Get or create OIDC user (using 'sub' as unique identifier)
|
||||
const userId = usersQueries.getOrCreateOidcUser(claims.sub);
|
||||
|
||||
// Capture session metadata
|
||||
const ipAddress = getClientIp(event);
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const parsed = parseUserAgent(userAgent);
|
||||
|
||||
// Create session
|
||||
const durationHours = authSettingsQueries.getSessionDurationHours();
|
||||
const sessionId = sessionsQueries.create(userId, durationHours, {
|
||||
ipAddress,
|
||||
userAgent,
|
||||
browser: parsed.browser,
|
||||
os: parsed.os,
|
||||
deviceType: parsed.deviceType
|
||||
});
|
||||
|
||||
await logger.info(`OIDC login successful for '${claims.sub}'`, {
|
||||
source: 'Auth:OIDC',
|
||||
meta: { sub: claims.sub, ip: ipAddress, browser: parsed.browser, device: parsed.deviceType }
|
||||
});
|
||||
|
||||
// Set session cookie
|
||||
const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000);
|
||||
cookies.set('session', sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires
|
||||
});
|
||||
|
||||
// Redirect to home
|
||||
throw redirect(303, '/');
|
||||
};
|
||||
58
src/routes/auth/oidc/login/+server.ts
Normal file
58
src/routes/auth/oidc/login/+server.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { config } from '$config';
|
||||
import { getDiscoveryDocument, generateState, buildAuthorizationUrl } from '$auth/oidc.ts';
|
||||
import { getClientIp } from '$auth/network.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { cookies } = event;
|
||||
const ip = getClientIp(event);
|
||||
|
||||
// Validate OIDC configuration
|
||||
if (config.authMode !== 'oidc') {
|
||||
throw error(400, 'OIDC authentication is not enabled');
|
||||
}
|
||||
|
||||
if (!config.oidc.discoveryUrl || !config.oidc.clientId || !config.oidc.clientSecret) {
|
||||
const missing = [
|
||||
!config.oidc.discoveryUrl && 'OIDC_DISCOVERY_URL',
|
||||
!config.oidc.clientId && 'OIDC_CLIENT_ID',
|
||||
!config.oidc.clientSecret && 'OIDC_CLIENT_SECRET'
|
||||
].filter(Boolean);
|
||||
|
||||
await logger.error(`OIDC config missing: ${missing.join(', ')}`, {
|
||||
source: 'Auth:OIDC',
|
||||
meta: { missing }
|
||||
});
|
||||
throw error(500, 'OIDC is not configured. Set OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET');
|
||||
}
|
||||
|
||||
await logger.debug('OIDC flow started', {
|
||||
source: 'Auth:OIDC',
|
||||
meta: { ip }
|
||||
});
|
||||
|
||||
// Fetch discovery document
|
||||
const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl);
|
||||
|
||||
// Generate state token for CSRF protection
|
||||
const state = generateState();
|
||||
|
||||
// Store state in cookie (10 minute expiry)
|
||||
cookies.set('oidc_state', state, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10
|
||||
});
|
||||
|
||||
// Build authorization URL and redirect
|
||||
const authUrl = buildAuthorizationUrl(discovery.authorization_endpoint, {
|
||||
clientId: config.oidc.clientId,
|
||||
redirectUri: `${config.serverUrl}/auth/oidc/callback`,
|
||||
state
|
||||
});
|
||||
|
||||
throw redirect(302, authUrl);
|
||||
};
|
||||
105
src/routes/auth/setup/+page.server.ts
Normal file
105
src/routes/auth/setup/+page.server.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { usersQueries } from '$db/queries/users.ts';
|
||||
import { sessionsQueries } from '$db/queries/sessions.ts';
|
||||
import { authSettingsQueries } from '$db/queries/authSettings.ts';
|
||||
import { hashPassword } from '$auth/password.ts';
|
||||
import { getClientIp } from '$auth/network.ts';
|
||||
import { parseUserAgent } from '$auth/userAgent.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = () => {
|
||||
// If local users already exist, redirect to home
|
||||
// (OIDC users don't count - they need to create a local account to use password auth)
|
||||
if (usersQueries.existsLocal()) {
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const { request, cookies } = event;
|
||||
|
||||
// Double-check no local users exist (race condition protection)
|
||||
if (usersQueries.existsLocal()) {
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const username = (formData.get('username') as string)?.trim();
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
// Validation
|
||||
if (!username) {
|
||||
return fail(400, { error: 'Username is required', username });
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
return fail(400, { error: 'Username must be at least 3 characters', username });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return fail(400, { error: 'Password is required', username });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return fail(400, { error: 'Password must be at least 8 characters', username });
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, { error: 'Passwords do not match', username });
|
||||
}
|
||||
|
||||
try {
|
||||
// Hash password and create user
|
||||
const passwordHash = await hashPassword(password);
|
||||
const userId = usersQueries.create(username, passwordHash);
|
||||
|
||||
if (!userId) {
|
||||
return fail(500, { error: 'Failed to create account', username });
|
||||
}
|
||||
|
||||
// Capture session metadata
|
||||
const ipAddress = getClientIp(event);
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const parsed = parseUserAgent(userAgent);
|
||||
|
||||
// Create session with metadata
|
||||
const durationHours = authSettingsQueries.getSessionDurationHours();
|
||||
const sessionId = sessionsQueries.create(userId, durationHours, {
|
||||
ipAddress,
|
||||
userAgent,
|
||||
browser: parsed.browser,
|
||||
os: parsed.os,
|
||||
deviceType: parsed.deviceType
|
||||
});
|
||||
|
||||
await logger.info(`Account created: '${username}'`, {
|
||||
source: 'Auth',
|
||||
meta: { username, ip: ipAddress }
|
||||
});
|
||||
|
||||
// Set session cookie
|
||||
const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000);
|
||||
cookies.set('session', sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires
|
||||
});
|
||||
|
||||
// Redirect to home
|
||||
throw redirect(303, '/');
|
||||
} catch (err) {
|
||||
// Re-throw redirects
|
||||
if (err instanceof Response || (err && typeof err === 'object' && 'status' in err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return fail(500, { error: 'Failed to create account', username });
|
||||
}
|
||||
}
|
||||
};
|
||||
125
src/routes/auth/setup/+page.svelte
Normal file
125
src/routes/auth/setup/+page.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
import { UserPlus, Shield, Wifi, KeyRound, ShieldOff } from 'lucide-svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import FormInput from '$ui/form/FormInput.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import logo from '$assets/logo-512.png';
|
||||
|
||||
export let form: ActionData;
|
||||
|
||||
let submitting = false;
|
||||
let username = form?.username ?? '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
|
||||
// Show errors via alert system
|
||||
$: if (form?.error) {
|
||||
alertStore.add('error', form.error);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Setup - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-neutral-100 dark:bg-neutral-900">
|
||||
<div class="flex w-full max-w-3xl gap-12 p-8">
|
||||
<!-- Left column: Branding and info -->
|
||||
<div class="flex flex-1 flex-col space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<img src={logo} alt="Profilarr logo" class="h-12 w-12" />
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-neutral-900 dark:text-neutral-50">Welcome to Profilarr</h1>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Create your admin account to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200/60 bg-white/50 p-5 shadow-sm backdrop-blur-sm dark:border-neutral-700/60 dark:bg-neutral-800/50">
|
||||
<p class="text-xs font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Configure authentication via the <code class="rounded bg-neutral-200 px-1 py-0.5 text-[11px] dark:bg-neutral-700">AUTH</code> environment variable:
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<li class="flex items-center gap-2">
|
||||
<Shield size={12} class="text-neutral-400" />
|
||||
<code class="font-mono text-neutral-800 dark:text-neutral-200">on</code>
|
||||
<span>— Full authentication</span>
|
||||
<span class="text-neutral-400 dark:text-neutral-500">(default)</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<Wifi size={12} class="text-neutral-400" />
|
||||
<code class="font-mono text-neutral-800 dark:text-neutral-200">local</code>
|
||||
<span>— Skip auth for local network</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<KeyRound size={12} class="text-neutral-400" />
|
||||
<code class="font-mono text-neutral-800 dark:text-neutral-200">oidc</code>
|
||||
<span>— Use external provider</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<ShieldOff size={12} class="text-neutral-400" />
|
||||
<code class="font-mono text-neutral-800 dark:text-neutral-200">off</code>
|
||||
<span>— For reverse proxy setups</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Form -->
|
||||
<div class="flex-1">
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-6"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<FormInput
|
||||
name="username"
|
||||
label="Username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
autocomplete="username"
|
||||
bind:value={username}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Minimum 8 characters"
|
||||
autocomplete="new-password"
|
||||
private_
|
||||
bind:value={password}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
autocomplete="new-password"
|
||||
private_
|
||||
bind:value={confirmPassword}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
icon={UserPlus}
|
||||
text={submitting ? 'Creating Account...' : 'Create Account'}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
145
src/routes/settings/security/+page.server.ts
Normal file
145
src/routes/settings/security/+page.server.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { usersQueries } from '$db/queries/users.ts';
|
||||
import { sessionsQueries } from '$db/queries/sessions.ts';
|
||||
import { authSettingsQueries } from '$db/queries/authSettings.ts';
|
||||
import { hashPassword, verifyPassword } from '$auth/password.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ cookies }) => {
|
||||
const currentSessionId = cookies.get('session');
|
||||
const user = usersQueries.getByUsername('admin') ?? usersQueries.getById(1);
|
||||
|
||||
if (!user) {
|
||||
return { sessions: [], apiKey: null, currentSessionId: null };
|
||||
}
|
||||
|
||||
const sessions = sessionsQueries.getByUserId(user.id);
|
||||
const apiKey = authSettingsQueries.getApiKey();
|
||||
|
||||
return {
|
||||
sessions: sessions.map(s => ({
|
||||
id: s.id,
|
||||
created_at: s.created_at,
|
||||
expires_at: s.expires_at,
|
||||
last_active_at: s.last_active_at,
|
||||
ip_address: s.ip_address,
|
||||
browser: s.browser,
|
||||
os: s.os,
|
||||
device_type: s.device_type,
|
||||
isCurrent: s.id === currentSessionId
|
||||
})),
|
||||
apiKey,
|
||||
currentSessionId
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
changePassword: async ({ request, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const currentPassword = formData.get('currentPassword') as string;
|
||||
const newPassword = formData.get('newPassword') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return fail(400, { passwordError: 'All fields are required' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return fail(400, { passwordError: 'New password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return fail(400, { passwordError: 'Passwords do not match' });
|
||||
}
|
||||
|
||||
// Get current user from session
|
||||
const sessionId = cookies.get('session');
|
||||
if (!sessionId) {
|
||||
return fail(401, { passwordError: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const session = sessionsQueries.getValidById(sessionId);
|
||||
if (!session) {
|
||||
return fail(401, { passwordError: 'Invalid session' });
|
||||
}
|
||||
|
||||
const user = usersQueries.getById(session.user_id);
|
||||
if (!user) {
|
||||
return fail(401, { passwordError: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const valid = await verifyPassword(currentPassword, user.password_hash);
|
||||
if (!valid) {
|
||||
return fail(400, { passwordError: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Update password
|
||||
const newHash = await hashPassword(newPassword);
|
||||
usersQueries.updatePassword(user.id, newHash);
|
||||
|
||||
await logger.info(`Password changed for '${user.username}'`, {
|
||||
source: 'Auth',
|
||||
meta: { userId: user.id, username: user.username }
|
||||
});
|
||||
|
||||
return { passwordSuccess: true };
|
||||
},
|
||||
|
||||
regenerateApiKey: async () => {
|
||||
const newKey = authSettingsQueries.regenerateApiKey();
|
||||
|
||||
await logger.info('API key regenerated', {
|
||||
source: 'Auth:APIKey'
|
||||
});
|
||||
|
||||
return { apiKey: newKey, apiKeyRegenerated: true };
|
||||
},
|
||||
|
||||
revokeSession: async ({ request, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const sessionId = formData.get('sessionId') as string;
|
||||
const currentSessionId = cookies.get('session');
|
||||
|
||||
if (!sessionId) {
|
||||
return fail(400, { sessionError: 'Session ID required' });
|
||||
}
|
||||
|
||||
if (sessionId === currentSessionId) {
|
||||
return fail(400, { sessionError: 'Cannot revoke current session' });
|
||||
}
|
||||
|
||||
sessionsQueries.deleteById(sessionId);
|
||||
|
||||
await logger.info('Session revoked', {
|
||||
source: 'Auth:Session',
|
||||
meta: { revokedSessionId: sessionId.slice(0, 8) + '...' }
|
||||
});
|
||||
|
||||
return { sessionRevoked: true };
|
||||
},
|
||||
|
||||
revokeOtherSessions: async ({ cookies }) => {
|
||||
const currentSessionId = cookies.get('session');
|
||||
if (!currentSessionId) {
|
||||
return fail(401, { sessionError: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const session = sessionsQueries.getValidById(currentSessionId);
|
||||
if (!session) {
|
||||
return fail(401, { sessionError: 'Invalid session' });
|
||||
}
|
||||
|
||||
const count = sessionsQueries.deleteOthersByUserId(session.user_id, currentSessionId);
|
||||
|
||||
if (count > 0) {
|
||||
await logger.info(`Revoked ${count} other session${count === 1 ? '' : 's'}`, {
|
||||
source: 'Auth:Session',
|
||||
meta: { userId: session.user_id, count }
|
||||
});
|
||||
}
|
||||
|
||||
return { sessionsRevoked: count };
|
||||
}
|
||||
};
|
||||
326
src/routes/settings/security/+page.svelte
Normal file
326
src/routes/settings/security/+page.svelte
Normal file
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { Copy, RefreshCw, LogOut, Check, Globe, Monitor, Smartphone, Network, Clock } from 'lucide-svelte';
|
||||
import { parseUTC } from '$shared/dates';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import FormInput from '$ui/form/FormInput.svelte';
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import TableActionButton from '$ui/table/TableActionButton.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import type { Column } from '$ui/table/types';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
|
||||
let changingPassword = false;
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
let confirmPassword = '';
|
||||
|
||||
let showApiKey = false;
|
||||
let regeneratingKey = false;
|
||||
|
||||
// Handle form responses
|
||||
$: if (form?.passwordSuccess) {
|
||||
alertStore.add('success', 'Password changed successfully');
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
}
|
||||
$: if (form?.passwordError) {
|
||||
alertStore.add('error', form.passwordError);
|
||||
}
|
||||
$: if (form?.apiKeyRegenerated) {
|
||||
alertStore.add('success', 'API key regenerated');
|
||||
}
|
||||
$: if (form?.sessionRevoked) {
|
||||
alertStore.add('success', 'Session revoked');
|
||||
}
|
||||
$: if (form?.sessionsRevoked !== undefined) {
|
||||
alertStore.add('success', `Revoked ${form.sessionsRevoked} session(s)`);
|
||||
}
|
||||
$: if (form?.sessionError) {
|
||||
alertStore.add('error', form.sessionError);
|
||||
}
|
||||
|
||||
// Get API key from form response or data
|
||||
$: apiKey = form?.apiKey ?? data.apiKey;
|
||||
|
||||
function copyApiKey() {
|
||||
if (apiKey) {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
alertStore.add('success', 'API key copied to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = parseUTC(dateStr);
|
||||
return date ? date.toLocaleString() : '';
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
last_active_at: string | null;
|
||||
ip_address: string | null;
|
||||
browser: string | null;
|
||||
os: string | null;
|
||||
device_type: string | null;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = parseUTC(dateStr);
|
||||
if (!date) return 'Unknown';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
const sessionColumns: Column<SessionRow>[] = [
|
||||
{
|
||||
key: 'browser',
|
||||
header: 'Browser',
|
||||
headerIcon: Globe,
|
||||
cell: (row) => row.browser ?? 'Unknown'
|
||||
},
|
||||
{
|
||||
key: 'os',
|
||||
header: 'OS',
|
||||
headerIcon: Monitor,
|
||||
cell: (row) => row.os ?? 'Unknown'
|
||||
},
|
||||
{
|
||||
key: 'device_type',
|
||||
header: 'Device',
|
||||
headerIcon: Smartphone,
|
||||
cell: (row) => row.device_type ?? 'Unknown'
|
||||
},
|
||||
{
|
||||
key: 'ip_address',
|
||||
header: 'IP',
|
||||
headerIcon: Network,
|
||||
cell: (row) => ({
|
||||
html: `<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">${row.ip_address ?? 'Unknown'}</span>`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'last_active_at',
|
||||
header: 'Last Active',
|
||||
headerIcon: Clock,
|
||||
cell: (row) => ({
|
||||
html: `<span class="text-xs text-neutral-500 dark:text-neutral-400">${formatRelativeTime(row.last_active_at)}</span>`
|
||||
})
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Security</h1>
|
||||
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
|
||||
Manage your password, API key, and active sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Change Password -->
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Change Password</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Update your account password
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/changePassword"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
changingPassword = true;
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
changingPassword = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<FormInput
|
||||
name="currentPassword"
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
autocomplete="current-password"
|
||||
private_
|
||||
bind:value={currentPassword}
|
||||
/>
|
||||
<FormInput
|
||||
name="newPassword"
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Minimum 8 characters"
|
||||
autocomplete="new-password"
|
||||
private_
|
||||
bind:value={newPassword}
|
||||
/>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
name="confirmPassword"
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Re-enter new password"
|
||||
autocomplete="new-password"
|
||||
private_
|
||||
bind:value={confirmPassword}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Check}
|
||||
iconColor="text-accent-500"
|
||||
text={changingPassword ? 'Saving...' : 'Change Password'}
|
||||
disabled={changingPassword}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">API Key</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Authenticate API requests via <code class="rounded bg-neutral-100 px-1 py-0.5 text-xs dark:bg-neutral-800">X-Api-Key</code> header
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{#if apiKey}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
name="apiKey"
|
||||
label=""
|
||||
type="password"
|
||||
value={apiKey}
|
||||
readonly
|
||||
private_
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
||||
title="Copy"
|
||||
onclick={copyApiKey}
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
<form method="POST" action="?/regenerateApiKey" use:enhance={() => {
|
||||
regeneratingKey = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
regeneratingKey = false;
|
||||
};
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
||||
title="Regenerate"
|
||||
disabled={regeneratingKey}
|
||||
>
|
||||
<RefreshCw size={18} class={regeneratingKey ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-4">
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No API key configured</p>
|
||||
<form method="POST" action="?/regenerateApiKey" use:enhance>
|
||||
<Button type="submit" variant="secondary" size="sm" text="Generate Key" />
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div class="flex items-start justify-between border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Active Sessions</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Manage your logged-in sessions across devices
|
||||
</p>
|
||||
</div>
|
||||
{#if data.sessions.length > 1}
|
||||
<form method="POST" action="?/revokeOtherSessions" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
await invalidateAll();
|
||||
};
|
||||
}}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
icon={LogOut}
|
||||
iconColor="text-red-500"
|
||||
text="Revoke Others"
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{#if data.sessions.length > 0}
|
||||
<Table
|
||||
columns={sessionColumns}
|
||||
data={data.sessions}
|
||||
compact
|
||||
actionsHeader="Status"
|
||||
>
|
||||
<svelte:fragment slot="actions" let:row>
|
||||
{#if row.isCurrent}
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200">Current</span>
|
||||
{:else}
|
||||
<form method="POST" action="?/revokeSession" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
await invalidateAll();
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="sessionId" value={row.id} />
|
||||
<TableActionButton
|
||||
icon={LogOut}
|
||||
title="Revoke session"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
type="submit"
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Table>
|
||||
{:else}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No active sessions</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +29,8 @@ const config = {
|
||||
$utils: './src/lib/server/utils',
|
||||
$notifications: './src/lib/server/notifications',
|
||||
$cache: './src/lib/server/utils/cache',
|
||||
$sync: './src/lib/server/sync'
|
||||
$sync: './src/lib/server/sync',
|
||||
$auth: './src/lib/server/utils/auth'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
plugins: [deno(), tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 6969,
|
||||
host: true,
|
||||
watch: {
|
||||
// Ignore temporary files created by editors
|
||||
ignored: ['**/*.tmp.*', '**/*~', '**/.#*']
|
||||
|
||||
Reference in New Issue
Block a user