From 008d295ffbe52fa0894645d48b1c8a76cbbd0606 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 03:10:56 -0400 Subject: [PATCH] Add login security retention and legal terms --- database/schema.sql | 15 +++++ docs/DESIGN_SYSTEM.md | 11 ++++ scripts/db-migrate.mjs | 22 +++++++ src/app.css | 2 + src/lib/components/AppShell.svelte | 6 ++ src/lib/server/auth/account-deletion.ts | 12 ++-- src/lib/server/auth/session.ts | 66 +++++++++++++++++-- src/routes/account/+page.server.ts | 3 +- src/routes/account/+page.svelte | 2 +- src/routes/admin/users/[id]/+page.server.ts | 18 ++++- src/routes/admin/users/[id]/+page.svelte | 2 +- src/routes/auth/discord/callback/+server.ts | 8 +-- src/routes/auth/magic-link/+server.ts | 8 +-- .../passkey/authenticate/verify/+server.ts | 10 +-- src/routes/sign-in/+page.server.ts | 16 ++--- src/routes/terms/+page.svelte | 31 +++++++++ 16 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 src/routes/terms/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index 85a3461..bf74a54 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -42,6 +42,21 @@ CREATE TABLE IF NOT EXISTS login_events ( user_id TEXT REFERENCES users(id) ON DELETE SET NULL, provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')), succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)), + ip_address TEXT, + user_agent TEXT, + client_name TEXT, + operating_system TEXT, + device_type TEXT, + location_country TEXT, + location_region TEXT, + location_city TEXT, + failure_reason TEXT, + risk_flags TEXT, + credential_id TEXT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + session_ended_at TEXT, + session_end_reason TEXT, + sensitive_purged_at TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS user_emails ( diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index c5fa51d..2ac1ab8 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -155,6 +155,17 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps an account through verified email, OAuth, or local credentials, then enroll a passkey from Account Access. +### Footer and legal surfaces + +- Every routed page ends with the global SHD footer containing Terms & Privacy, + data-retention access, and the unofficial Ubisoft fan-site disclaimer. +- Legal pages retain the command-console visual language but prioritize readable + serif body copy, anchored sections, and restrained line length. +- Login security metadata is operationally visible in ISAC for 30 days. After + that period, IP, user-agent, normalized client/device fields, and credential + references and proxy-supplied approximate location are scrubbed while + non-sensitive event facts, categorical risk flags, and session outcomes may remain. + ### Admin dashboard - Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and `admin-content`. diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 2ec76fc..1fdbb06 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -58,6 +58,28 @@ if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql await database.exec('DROP TABLE login_events_legacy'); await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)'); } +const loginEventColumns = await (await database.prepare("PRAGMA table_info('login_events')")).all(); +for (const [name, definition] of [ + ['ip_address', 'TEXT'], + ['user_agent', 'TEXT'], + ['client_name', 'TEXT'], + ['operating_system', 'TEXT'], + ['device_type', 'TEXT'], + ['location_country', 'TEXT'], + ['location_region', 'TEXT'], + ['location_city', 'TEXT'], + ['failure_reason', 'TEXT'], + ['risk_flags', 'TEXT'], + ['credential_id', 'TEXT'], + ['session_id', 'TEXT REFERENCES sessions(id) ON DELETE SET NULL'], + ['session_ended_at', 'TEXT'], + ['session_end_reason', 'TEXT'], + ['sensitive_purged_at', 'TEXT'] +]) { + if (!loginEventColumns.some((column) => column.name === name)) { + await database.exec(`ALTER TABLE login_events ADD COLUMN ${name} ${definition}`); + } +} console.log(`Applied ${statements.length} schema statements to ${databasePath}`); diff --git a/src/app.css b/src/app.css index f35010d..ef9714c 100644 --- a/src/app.css +++ b/src/app.css @@ -92,6 +92,8 @@ @media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}} .isac-operation-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:10px 0 0;border:1px solid var(--shd-border);background:#0d1011}.isac-operation-tabs button{height:40px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.isac-operation-tabs button:first-child{border-left:0}.isac-operation-tabs button b{margin-left:5px;color:var(--shd-success)}.isac-operation-tabs button:hover,.isac-operation-tabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.isac-operation-tabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.isac-operation-tabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.user-console-grid{grid-template-columns:minmax(0,1fr)}.isac-panel-hidden{display:none!important}.user-history-registries{display:grid;gap:10px;margin-top:10px}.history-registry{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.history-filter{display:grid;grid-template-columns:minmax(180px,1fr) 170px 80px;gap:6px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}.history-filter input,.history-filter select{height:34px;margin:0;padding:0 9px;font-size:8px}.history-filter button{height:34px;padding:0 9px;font-size:7px}.history-registry:last-child .history-filter{grid-template-columns:minmax(180px,1fr) 80px}.history-registry .registry-pagination{margin:0;border-width:0 0 1px}.history-registry .history-list{max-height:none}.history-registry .comment-history>div{grid-template-columns:minmax(140px,.5fr) minmax(260px,1.5fr) auto}.history-registry .comment-history span{white-space:normal;line-height:1.4}@media(max-width:650px){.isac-operation-tabs,.history-filter,.history-registry:last-child .history-filter{grid-template-columns:1fr}.isac-operation-tabs button{border-left:0;border-top:1px solid var(--shd-border)}.isac-operation-tabs button:first-child{border-top:0}} .isac-modal-backdrop{position:fixed;z-index:50;inset:0;display:grid;place-items:center;padding:20px;background:#050607d9;backdrop-filter:blur(3px)}.isac-confirm-modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:16px;border:1px solid #5a6061;border-top:2px solid var(--shd-orange);background:#131617;box-shadow:0 24px 80px #000}.access-confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.access-confirm-grid>div{min-height:120px;align-content:start;display:grid;gap:5px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.access-confirm-grid>div>span{margin-bottom:3px;color:var(--shd-text-dim);font-size:7px;letter-spacing:1px}.access-confirm-grid b{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 7px;border-left:2px solid #596164;background:#151819;color:#aeb3b4;font-size:8px}.access-confirm-grid b span{color:inherit}.access-confirm-grid b em{font-style:normal;color:#747c7e;font-size:6px;letter-spacing:.8px}.access-confirm-grid b.access-add{border-left-color:var(--shd-success);background:#101713;color:#9bd4ac}.access-confirm-grid b.access-add em{color:var(--shd-success)}.access-confirm-grid b.access-remove{border-left-color:var(--shd-danger);background:#211414;color:#dca096;text-decoration:line-through}.access-confirm-grid b.access-remove em{color:var(--shd-danger);text-decoration:none}.access-confirm-grid small{color:var(--shd-text-dim);font-size:7px}@media(max-width:560px){.access-confirm-grid{grid-template-columns:1fr}} +.login-investigation-list>div{grid-template-columns:1fr 1fr 1.2fr auto;align-items:center}.login-investigation-list>div>div{display:grid;gap:4px}.login-investigation-list small{color:var(--shd-text-dim);font-size:6px;letter-spacing:.4px}.deletion-purge-option{display:grid!important;grid-template-columns:20px 1fr;align-items:start;gap:8px;margin:14px 0!important;padding:10px;border:1px solid #70423b;background:#211414}.deletion-purge-option input{width:auto;margin:2px 0 0;accent-color:var(--shd-danger)}.deletion-purge-option span{display:grid;gap:4px}.deletion-purge-option b{color:var(--shd-danger);font-size:8px}.deletion-purge-option small{margin:0;color:#b18c85;font-size:7px} +.site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.footer-brand .brand-mark{width:30px;height:30px;font-size:7px}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px Georgia,serif}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.fan-disclaimer{margin:0;color:#555d5f;font-size:6px;line-height:1.6;letter-spacing:.55px}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 Georgia,serif}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:9px;font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 Georgia,serif}.legal-document a{color:var(--shd-orange)}@media(max-width:800px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} .dashboard-row>.role-summary{display:flex;flex-wrap:wrap;align-items:center;gap:3px}.role-summary b{flex:0 0 auto;width:max-content;padding:3px 5px;border:1px solid #6e5226;color:var(--shd-orange);font-size:6px;letter-spacing:.6px} .weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 6da83d9..ea0d4eb 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -42,3 +42,9 @@ {@render children()} + + diff --git a/src/lib/server/auth/account-deletion.ts b/src/lib/server/auth/account-deletion.ts index af3a4c2..4fad2e1 100644 --- a/src/lib/server/auth/account-deletion.ts +++ b/src/lib/server/auth/account-deletion.ts @@ -6,7 +6,8 @@ export async function softDeleteAccount( database: Database, userId: string, deletedBy: string, - reason: string | null + reason: string | null, + purgeSecurityData = false ): Promise { const user = await (await database.prepare(` SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at @@ -20,7 +21,7 @@ export async function softDeleteAccount( await (await database.prepare( 'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)' - )).run(userId, String(user.login_username ?? user.username), now, deletedBy, reason); + )).run(userId, purgeSecurityData ? deletedUsername : String(user.login_username ?? user.username), now, deletedBy, reason); await (await database.prepare( `UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?` )).run(deletedUsername, userId); @@ -38,6 +39,9 @@ export async function softDeleteAccount( await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(userId); await deleteUploadedAvatar(database, userId); + if (purgeSecurityData) { + await (await database.prepare('DELETE FROM login_events WHERE user_id=?')).run(userId); + } await (await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)' )).run( @@ -46,8 +50,8 @@ export async function softDeleteAccount( 'user', userId, 'account.soft_deleted', - JSON.stringify({ username: user.username, displayName: user.display_name }), - JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason }), + purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }), + JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason, securityDataPurged: purgeSecurityData }), now ); return true; diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index 46f8085..95364b6 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -17,13 +17,23 @@ export type AuthUser = { avatarUrl: string; }; +type LoginRequestContext = { + request?: Request; + getClientAddress?: () => string; + failureReason?: string | null; + credentialId?: string | null; + sessionId?: string | null; + riskFlags?: string[] | null; +}; + function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } -export async function createSession(cookies: Cookies, userId: string): Promise { +export async function createSession(cookies: Cookies, userId: string): Promise { const database = await getDatabase(); const token = randomBytes(32).toString('base64url'); + const sessionId = randomUUID(); const now = new Date(); const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000); const insert = await database.prepare( @@ -31,7 +41,7 @@ export async function createSession(cookies: Cookies, userId: string): Promise { +function describeClient(userAgent: string) { + const clientName = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' : + /Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : 'Other'; + const operatingSystem = /Windows/.test(userAgent) ? 'Windows' : /Android/.test(userAgent) ? 'Android' : + /iPhone|iPad/.test(userAgent) ? 'iOS/iPadOS' : /Mac OS X/.test(userAgent) ? 'macOS' : + /Linux/.test(userAgent) ? 'Linux' : 'Other'; + const deviceType = /Mobile|Android|iPhone/.test(userAgent) ? 'Mobile' : /iPad|Tablet/.test(userAgent) ? 'Tablet' : 'Desktop'; + return { clientName, operatingSystem, deviceType }; +} + +export async function purgeExpiredLoginMetadata(): Promise { const database = await getDatabase(); - await (await database.prepare('INSERT INTO login_events(id,user_id,provider,succeeded,created_at) VALUES(?,?,?,?,?)')) - .run(randomUUID(), userId, provider, succeeded ? 1 : 0, new Date().toISOString()); + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + await (await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL, + operating_system=NULL,device_type=NULL,location_country=NULL,location_region=NULL,location_city=NULL, + credential_id=NULL,session_id=NULL,sensitive_purged_at=? + WHERE created_at { + const database = await getDatabase(); + const userAgent = context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null; + const headers = context.request?.headers; + const locationCountry = headers?.get('cf-ipcountry') ?? headers?.get('x-vercel-ip-country') ?? headers?.get('x-geo-country'); + const locationRegion = headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region'); + const locationCity = headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city'); + const client = describeClient(userAgent ?? ''); + const riskFlags = context.riskFlags ?? (!succeeded + ? [userId ? 'failed_authentication' : 'unknown_account_attempt'] + : []); + let ipAddress: string | null = null; + try { ipAddress = context.getClientAddress?.() ?? null; } catch { /* unavailable in some development adapters */ } + await (await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent, + client_name,operating_system,device_type,location_country,location_region,location_city, + failure_reason,risk_flags,credential_id,session_id,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + randomUUID(), userId, provider, succeeded ? 1 : 0, ipAddress, userAgent, + client.clientName, client.operatingSystem, client.deviceType, + locationCountry?.slice(0, 100) ?? null, locationRegion?.slice(0, 100) ?? null, locationCity?.slice(0, 100) ?? null, + context.failureReason ?? null, riskFlags.length ? riskFlags.join(',') : null, + context.credentialId ?? null, context.sessionId ?? null, new Date().toISOString() + ); + await purgeExpiredLoginMetadata(); } export async function getSessionUser(cookies: Cookies): Promise { @@ -107,6 +158,11 @@ export async function destroySession(cookies: Cookies): Promise { if (token) { const database = await getDatabase(); + const session = await (await database.prepare('SELECT id FROM sessions WHERE token_hash=?')).get(hashToken(token)); + if (session?.id) { + await (await database.prepare('UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?')) + .run(new Date().toISOString(), 'user_logout', session.id); + } const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?'); await statement.run(hashToken(token)); } diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 1f0ccfb..1239e92 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -222,13 +222,14 @@ export const actions: Actions = { const form = await request.formData(); const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim(); const password = String(form.get('password') ?? ''); + const purgeSecurityData = form.get('purgeSecurityData') === 'on'; if (confirmation !== 'DELETE') return fail(400, { deleteError: 'Enter DELETE exactly to confirm deletion.' }); const database = await getDatabase(); const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id); if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) { return fail(400, { deleteError: 'Your password is incorrect.' }); } - if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner'))) { + if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner', purgeSecurityData))) { return fail(409, { deleteError: 'This account has already been deleted.' }); } cookies.delete('shd_session', { path: '/' }); diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 5405ccd..9dc9724 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -182,7 +182,7 @@ {/if} diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 0cf0709..83debfc 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -9,18 +9,23 @@ import { avatarUrl } from '$lib/avatar'; import { deleteUploadedAvatar } from '$lib/server/avatars'; import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods'; import { getPagination } from '$lib/server/pagination'; +import { purgeExpiredLoginMetadata } from '$lib/server/auth/session'; export const load: PageServerLoad = async ({ cookies, params, url }) => { const operator = await requirePermission(await getSessionUser(cookies), 'users.view'); const database = await getDatabase(); + await purgeExpiredLoginMetadata(); const loginQuery = url.searchParams.get('loginQ')?.trim() ?? ''; const loginStatus = ['success', 'failed'].includes(url.searchParams.get('loginStatus') ?? '') ? String(url.searchParams.get('loginStatus')) : ''; const buildQuery = url.searchParams.get('buildQ')?.trim() ?? ''; const buildVisibility = ['draft', 'private', 'public'].includes(url.searchParams.get('buildVisibility') ?? '') ? String(url.searchParams.get('buildVisibility')) : ''; const commentQuery = url.searchParams.get('commentQ')?.trim() ?? ''; const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; - const loginWhere = `user_id=? AND provider LIKE ? ESCAPE '\\'${loginStatus ? ' AND succeeded=?' : ''}`; - const loginArgs: unknown[] = [params.id, pattern(loginQuery), ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])]; + const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\' + OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\' + OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`; + const loginPattern = pattern(loginQuery); + const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])]; const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`; const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])]; const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`; @@ -51,7 +56,14 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => { (await database.prepare('SELECT * FROM permissions ORDER BY name')).all(), (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id), (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id), - (await database.prepare(`SELECT provider,succeeded,created_at FROM login_events WHERE ${loginWhere} ORDER BY created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset), + (await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at, + login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system, + login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city, + login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id, + login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at, + sessions.expires_at AS session_expires_at + FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id + WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset), (await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset), (await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset) diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index 076125a..108e9c1 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -105,7 +105,7 @@
-
LOGIN HISTORY{data.historyPagination.login.total} EVENTS
{#each data.logins as login}
{login.provider.toUpperCase()}{date(login.created_at)}{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}
{:else}

NO LOGIN EVENTS MATCH THESE FILTERS

{/each}
+
LOGIN HISTORY · SENSITIVE METADATA RETAINED 30 DAYS{data.historyPagination.login.total} EVENTS
BUILD & GUIDE HISTORY{data.historyPagination.build.total} RECORDS
{#each data.builds as build}
{build.title}{date(build.updated_at)}{build.visibility.toUpperCase()}
{:else}

NO BUILDS MATCH THESE FILTERS

{/each}
COMMENT HISTORY{data.historyPagination.comment.total} RECORDS
{#each data.comments as comment}
{comment.title}{comment.body}{date(comment.created_at)}
{:else}

NO COMMENTS MATCH THIS SEARCH

{/each}
diff --git a/src/routes/auth/discord/callback/+server.ts b/src/routes/auth/discord/callback/+server.ts index a9c2174..c454efe 100644 --- a/src/routes/auth/discord/callback/+server.ts +++ b/src/routes/auth/discord/callback/+server.ts @@ -38,7 +38,7 @@ async function uniqueUsername(): Promise { return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`; } -export const GET: RequestHandler = async ({ cookies, url, fetch }) => { +export const GET: RequestHandler = async ({ cookies, url, fetch, request, getClientAddress }) => { const expectedState = cookies.get(STATE_COOKIE); cookies.delete(STATE_COOKIE, { path: '/auth/discord' }); @@ -128,11 +128,11 @@ export const GET: RequestHandler = async ({ cookies, url, fetch }) => { const status = await (await database.prepare('SELECT blocked_at,banned_until FROM user_status WHERE user_id=?')).get(String(user.id)); const banned = typeof status?.banned_until === 'string' && new Date(status.banned_until) > new Date(); if (status?.blocked_at || banned) { - await recordLogin(String(user.id), 'discord', false); + await recordLogin(String(user.id), 'discord', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: discordUser.id }); redirect(303, '/sign-in?oauth_error=account_blocked'); } await saveDiscordProfile(String(user.id), discordUser); - await createSession(cookies, String(user.id)); - await recordLogin(String(user.id), 'discord', true); + const sessionId = await createSession(cookies, String(user.id)); + await recordLogin(String(user.id), 'discord', true, { request, getClientAddress, credentialId: discordUser.id, sessionId }); redirect(303, created ? '/account?choose_display_name=1' : '/account'); }; diff --git a/src/routes/auth/magic-link/+server.ts b/src/routes/auth/magic-link/+server.ts index c866dd8..a407a8a 100644 --- a/src/routes/auth/magic-link/+server.ts +++ b/src/routes/auth/magic-link/+server.ts @@ -5,7 +5,7 @@ import { getDatabase } from '$lib/server/database'; import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async ({ url, cookies, request, getClientAddress }) => { const token = url.searchParams.get('token') ?? ''; const database = await getDatabase(); const link = token ? await consumeMagicLinkToken(database, token) : null; @@ -17,7 +17,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const unavailable = !user || user.deleted_at || user.blocked_at || (typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date()); if (unavailable) { - await recordLogin(link.userId, 'magic_link', false); + await recordLogin(link.userId, 'magic_link', false, { request, getClientAddress, failureReason: 'account_restricted' }); redirect(303, '/sign-in?magic_error=unavailable'); } @@ -28,7 +28,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { .run(link.userId, link.email, now, now); } await ensureBootstrapAdmin(database, link.userId); - await createSession(cookies, link.userId); - await recordLogin(link.userId, 'magic_link', true); + const sessionId = await createSession(cookies, link.userId); + await recordLogin(link.userId, 'magic_link', true, { request, getClientAddress, sessionId }); redirect(303, link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account'); }; diff --git a/src/routes/auth/passkey/authenticate/verify/+server.ts b/src/routes/auth/passkey/authenticate/verify/+server.ts index db3f579..ab0c8d3 100644 --- a/src/routes/auth/passkey/authenticate/verify/+server.ts +++ b/src/routes/auth/passkey/authenticate/verify/+server.ts @@ -17,7 +17,7 @@ function bytes(value: unknown): Uint8Array { throw new Error('Invalid passkey public key.'); } -export const POST: RequestHandler = async ({ cookies, request }) => { +export const POST: RequestHandler = async ({ cookies, request, getClientAddress }) => { const body = await request.json() as { challengeId?: string; response?: AuthenticationResponseJSON }; if (!body.challengeId || !body.response) error(400, 'Incomplete passkey authentication.'); const database = await getDatabase(); @@ -31,7 +31,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => { const unavailable = passkey.deleted_at || passkey.blocked_at || (typeof passkey.banned_until === 'string' && new Date(passkey.banned_until) > new Date()); if (unavailable) { - await recordLogin(String(passkey.user_id), 'passkey', false); + await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: String(passkey.id) }); error(403, 'This account is not permitted to sign in.'); } const transports = passkey.transports ? JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[] : undefined; @@ -44,14 +44,14 @@ export const POST: RequestHandler = async ({ cookies, request }) => { requireUserVerification: true }); if (!verification.verified) { - await recordLogin(String(passkey.user_id), 'passkey', false); + await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'verification_failed', credentialId: String(passkey.id) }); error(400, 'Passkey authentication could not be verified.'); } const now = new Date().toISOString(); await (await database.prepare('UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?')) .run(verification.authenticationInfo.newCounter, now, passkey.id); await ensureBootstrapAdmin(database, String(passkey.user_id)); - await createSession(cookies, String(passkey.user_id)); - await recordLogin(String(passkey.user_id), 'passkey', true); + const sessionId = await createSession(cookies, String(passkey.user_id)); + await recordLogin(String(passkey.user_id), 'passkey', true, { request, getClientAddress, credentialId: String(passkey.id), sessionId }); return json({ verified: true, redirectTo: '/account' }); }; diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts index 79ec9ec..c5591ff 100644 --- a/src/routes/sign-in/+page.server.ts +++ b/src/routes/sign-in/+page.server.ts @@ -52,7 +52,7 @@ export const actions: Actions = { } return { magicLinkSent: true }; }, - register: async ({ request, cookies }) => { + register: async ({ request, cookies, getClientAddress }) => { const form = await request.formData(); const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim(); const username = String(form.get('username') ?? '').normalize('NFKC').trim(); @@ -83,11 +83,11 @@ export const actions: Actions = { await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)')) .run(id, displayName, now); await ensureBootstrapAdmin(database, id); - await createSession(cookies, id); - await recordLogin(id, 'password', true); + const sessionId = await createSession(cookies, id); + await recordLogin(id, 'password', true, { request, getClientAddress, sessionId }); redirect(303, '/account'); }, - login: async ({ request, cookies }) => { + login: async ({ request, cookies, getClientAddress }) => { const form = await request.formData(); const username = String(form.get('username') ?? '').trim(); const password = String(form.get('password') ?? ''); @@ -97,17 +97,17 @@ export const actions: Actions = { FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)).get(username); const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password); if (!valid) { - if (user?.id) await recordLogin(String(user.id), 'password', false); + await recordLogin(user?.id ? String(user.id) : null, 'password', false, { request, getClientAddress, failureReason: 'invalid_credentials' }); return fail(400, { loginError: 'Invalid username or password.', username }); } const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date(); if (user.blocked_at || banned) { - await recordLogin(String(user.id), 'password', false); + await recordLogin(String(user.id), 'password', false, { request, getClientAddress, failureReason: 'account_restricted' }); return fail(403, { loginError: 'This account is not permitted to sign in.', username }); } await ensureBootstrapAdmin(database, String(user.id)); - await createSession(cookies, String(user.id)); - await recordLogin(String(user.id), 'password', true); + const sessionId = await createSession(cookies, String(user.id)); + await recordLogin(String(user.id), 'password', true, { request, getClientAddress, sessionId }); redirect(303, '/account'); }, logout: async ({ cookies }) => { diff --git a/src/routes/terms/+page.svelte b/src/routes/terms/+page.svelte new file mode 100644 index 0000000..21e1962 --- /dev/null +++ b/src/routes/terms/+page.svelte @@ -0,0 +1,31 @@ + + Terms & Privacy — SHD.build + + + +
+ + + +