From 2ccc7b82d7f8e989405ace63c6e8bfd8ce6ed968 Mon Sep 17 00:00:00 2001 From: Samuel James <143277412+SamuelSJames@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:50:56 -0400 Subject: [PATCH] Add auth Phase 2: password change, sessions, and login audit log (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds out the Settings → Security tab (previously a "coming soon" placeholder) and the backend behind it. Still single-user; multi-user and SSO remain Phases 3-4. Backend: - New `sessions` table (id, user_id, user_agent, ip, created_at, last_seen_at) and `login_events` table (user_id, username, ip, user_agent, success, created_at). - Login and setup now mint a session row and embed its id as a `sid` claim in the JWT. The `authenticate` hook validates that the session still exists (and bumps last_seen_at), so revoking a session genuinely invalidates its token instead of relying on the JWT signature alone. Tokens minted before sessions existed have no `sid` and stay valid until expiry, for backward compatibility. - Every login attempt (success and failure) is recorded in login_events for the audit trail. - New endpoints: PUT /api/auth/password (verifies current via bcrypt, hashes new at cost 12, revokes all *other* sessions on success), GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke the current one), POST /api/auth/logout (revokes current session), GET /api/auth/login-events?limit. - AuthContext.logout() now calls POST /api/auth/logout best-effort so signing out revokes the server session, not just the local token. Frontend: - SecuritySection: change-password form (current/new/confirm with show/hide and client-side validation), active-sessions list (device description from user-agent, IP, last-seen relative time, per-session "Sign out" for non-current sessions), and a recent login-activity feed (success/failure dot, user, IP, relative time). - api.ts: changePassword/listSessions/revokeSession/logout/ listLoginEvents + AuthSession/LoginEvent types. Verified end-to-end against a throwaway backend instance: session creation, second-device session, failed-login logging, cross-session revocation invalidating the revoked token, password change keeping the current session alive while revoking others, and logout invalidating the current session. Frontend + backend both type-check clean. Co-authored-by: Samuel James Co-authored-by: Kiro --- backend/src/db/index.ts | 19 +++ backend/src/routes/auth.ts | 123 ++++++++++++++++- backend/src/server.ts | 15 ++ backend/src/types.d.ts | 4 +- src/lib/AuthContext.tsx | 2 + src/lib/api.ts | 24 ++++ src/pages/Settings.tsx | 276 ++++++++++++++++++++++++++++++++++++- 7 files changed, 451 insertions(+), 12 deletions(-) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 7ccfa42..4f05f4b 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -85,6 +85,25 @@ db.exec(` retry_interval_ms INTEGER NOT NULL DEFAULT 5000, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_agent TEXT, + ip TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS login_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + username TEXT, + ip TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) export function logEvent(type: string, title: string, source?: string | null) { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 94fbf39..c0f5065 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,5 +1,6 @@ -import type { FastifyInstance } from 'fastify' +import type { FastifyInstance, FastifyRequest } from 'fastify' import bcrypt from 'bcryptjs' +import { randomUUID } from 'node:crypto' import { z } from 'zod' import { db, logEvent } from '../db/index.js' @@ -8,6 +9,29 @@ const credentialsSchema = z.object({ password: z.string().min(8).max(256), }) +function clientIp(req: FastifyRequest): string { + const fwd = req.headers['x-forwarded-for'] + if (typeof fwd === 'string' && fwd.length > 0) return fwd.split(',')[0]!.trim() + return req.ip +} + +function clientUserAgent(req: FastifyRequest): string { + const ua = req.headers['user-agent'] + return (typeof ua === 'string' ? ua : '').slice(0, 512) +} + +/** Creates a session row and returns a JWT carrying its id, so the session can be revoked. */ +function createSession(app: FastifyInstance, req: FastifyRequest, userId: number, username: string): string { + const sid = randomUUID() + db.prepare('INSERT INTO sessions (id, user_id, user_agent, ip) VALUES (?, ?, ?, ?)').run( + sid, + userId, + clientUserAgent(req), + clientIp(req), + ) + return app.jwt.sign({ sub: userId, username, sid }) +} + export async function authRoutes(app: FastifyInstance) { app.get('/api/system/setup-status', async () => { const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number } @@ -28,7 +52,8 @@ export async function authRoutes(app: FastifyInstance) { const result = db .prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') .run(username, passwordHash) - const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username }) + const userId = Number(result.lastInsertRowid) + const token = createSession(app, req, userId, username) logEvent('account_created', `Account created for ${username}`) return { token } }) @@ -42,10 +67,18 @@ export async function authRoutes(app: FastifyInstance) { const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as | { id: number; username: string; password_hash: string } | undefined - if (!user || !(await bcrypt.compare(password, user.password_hash))) { + const ok = !!user && (await bcrypt.compare(password, user.password_hash)) + db.prepare('INSERT INTO login_events (user_id, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)').run( + user?.id ?? null, + username, + clientIp(req), + clientUserAgent(req), + ok ? 1 : 0, + ) + if (!ok || !user) { return reply.code(401).send({ error: 'Invalid username or password' }) } - const token = app.jwt.sign({ sub: user.id, username: user.username }) + const token = createSession(app, req, user.id, user.username) logEvent('user_login', `${user.username} logged in`) return { token } }) @@ -79,4 +112,86 @@ export async function authRoutes(app: FastifyInstance) { .get(payload.sub) return { user } }) + + const passwordChangeSchema = z.object({ + currentPassword: z.string().min(1).max(256), + newPassword: z.string().min(8).max(256), + }) + + app.put('/api/auth/password', { onRequest: [app.authenticate] }, async (req, reply) => { + const payload = req.user as { sub: number; username: string; sid?: string } + const parsed = passwordChangeSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const { currentPassword, newPassword } = parsed.data + const user = db.prepare('SELECT id, username, password_hash FROM users WHERE id = ?').get(payload.sub) as + | { id: number; username: string; password_hash: string } + | undefined + if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) { + return reply.code(401).send({ error: 'Current password is incorrect' }) + } + const passwordHash = await bcrypt.hash(newPassword, 12) + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, user.id) + // Revoke every other session — a password change should sign out other devices, + // but keep the current session alive so the user isn't logged out of this tab. + if (payload.sid) { + db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(user.id, payload.sid) + } else { + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id) + } + logEvent('password_changed', `${user.username} changed their password`) + return { ok: true } + }) + + app.get('/api/auth/sessions', { onRequest: [app.authenticate] }, async (req) => { + const payload = req.user as { sub: number; sid?: string } + const rows = db + .prepare('SELECT id, user_agent, ip, created_at, last_seen_at FROM sessions WHERE user_id = ? ORDER BY last_seen_at DESC') + .all(payload.sub) as Array<{ id: string; user_agent: string | null; ip: string | null; created_at: string; last_seen_at: string }> + return { + sessions: rows.map((r) => ({ + id: r.id, + userAgent: r.user_agent, + ip: r.ip, + createdAt: r.created_at, + lastSeenAt: r.last_seen_at, + current: r.id === payload.sid, + })), + } + }) + + app.delete('/api/auth/sessions/:id', { onRequest: [app.authenticate] }, async (req, reply) => { + const payload = req.user as { sub: number; sid?: string } + const id = (req.params as { id: string }).id + if (id === payload.sid) { + return reply.code(400).send({ error: 'Cannot revoke the current session; use sign out instead' }) + } + db.prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?').run(id, payload.sub) + return { ok: true } + }) + + app.post('/api/auth/logout', { onRequest: [app.authenticate] }, async (req) => { + const payload = req.user as { sub: number; sid?: string } + if (payload.sid) db.prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?').run(payload.sid, payload.sub) + return { ok: true } + }) + + app.get('/api/auth/login-events', { onRequest: [app.authenticate] }, async (req) => { + const limitRaw = Number((req.query as { limit?: string }).limit) + const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 100) : 20 + const rows = db + .prepare('SELECT id, username, ip, user_agent, success, created_at FROM login_events ORDER BY created_at DESC LIMIT ?') + .all(limit) as Array<{ id: number; username: string | null; ip: string | null; user_agent: string | null; success: number; created_at: string }> + return { + events: rows.map((r) => ({ + id: r.id, + username: r.username, + ip: r.ip, + userAgent: r.user_agent, + success: r.success === 1, + createdAt: r.created_at, + })), + } + }) } diff --git a/backend/src/server.ts b/backend/src/server.ts index a04e19e..69c4e1f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import { metricsRoutes } from './routes/metrics.js' import { transferRoutes } from './routes/transfer.js' import { dataRoutes } from './routes/data.js' import { startAutoStartTunnels } from './tunnels/manager.js' +import { db } from './db/index.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET if (!JWT_SECRET) { @@ -35,6 +36,20 @@ app.decorate('authenticate', async function (req, reply) { await req.jwtVerify() } catch { reply.code(401).send({ error: 'Unauthorized' }) + return + } + // Session-aware check: if the token carries a session id, that session must still + // exist (so "Sign out" / revoking a session genuinely invalidates the token, not + // just relies on the JWT signature). Tokens minted before sessions existed have no + // `sid` and remain valid until they expire, for backward compatibility. + const payload = req.user as { sub: number; sid?: string } + if (payload.sid) { + const session = db.prepare('SELECT id FROM sessions WHERE id = ? AND user_id = ?').get(payload.sid, payload.sub) + if (!session) { + reply.code(401).send({ error: 'Session expired' }) + return + } + db.prepare("UPDATE sessions SET last_seen_at = datetime('now') WHERE id = ?").run(payload.sid) } }) diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts index 3f72503..8ea6ec3 100644 --- a/backend/src/types.d.ts +++ b/backend/src/types.d.ts @@ -8,7 +8,7 @@ declare module 'fastify' { declare module '@fastify/jwt' { interface FastifyJWT { - payload: { sub: number; username: string } - user: { sub: number; username: string } + payload: { sub: number; username: string; sid?: string } + user: { sub: number; username: string; sid?: string } } } diff --git a/src/lib/AuthContext.tsx b/src/lib/AuthContext.tsx index cd0ae31..ff03345 100644 --- a/src/lib/AuthContext.tsx +++ b/src/lib/AuthContext.tsx @@ -57,6 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } function logout() { + // Best-effort server-side session revocation; clear client state regardless. + api.logout().catch(() => {}) setToken(null) setUser(null) setStatus('logged-out') diff --git a/src/lib/api.ts b/src/lib/api.ts index 50ee4bd..c2d9728 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -51,6 +51,12 @@ export const api = { me: () => apiFetch<{ user: AuthUser }>('/auth/me'), updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) => apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }), + changePassword: (currentPassword: string, newPassword: string) => + apiFetch<{ ok: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }), + listSessions: () => apiFetch<{ sessions: AuthSession[] }>('/auth/sessions'), + revokeSession: (id: string) => apiFetch<{ ok: boolean }>(`/auth/sessions/${id}`, { method: 'DELETE' }), + logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }), + listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`), listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'), createIntegration: (data: { type: string; name: string; config?: Record; secrets?: Record }) => @@ -179,6 +185,24 @@ export interface AuthUser { avatar_data_url: string | null } +export interface AuthSession { + id: string + userAgent: string | null + ip: string | null + createdAt: string + lastSeenAt: string + current: boolean +} + +export interface LoginEvent { + id: number + username: string | null + ip: string | null + userAgent: string | null + success: boolean + createdAt: string +} + export interface Integration { id: number type: string diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index b0cf418..42f196e 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { api, ApiError, type Integration } from '../lib/api' +import { api, ApiError, type Integration, type AuthSession, type LoginEvent } from '../lib/api' import { useAuth } from '../lib/AuthContext' import { User, @@ -19,6 +19,8 @@ import { ChevronDown, ChevronRight, Shield, + Monitor, + LogOut, } from 'lucide-react' const navSections = [ @@ -1257,13 +1259,275 @@ function AboutSection() { ) } +function relativeTime(iso: string): string { + // SQLite datetime('now') returns UTC without a timezone marker; treat it as UTC. + const ts = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z') + if (Number.isNaN(ts)) return iso + const diffMs = Date.now() - ts + const sec = Math.round(diffMs / 1000) + if (sec < 60) return 'just now' + const min = Math.round(sec / 60) + if (min < 60) return `${min}m ago` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.round(hr / 24) + if (day < 30) return `${day}d ago` + return new Date(ts).toLocaleDateString() +} + +function describeUserAgent(ua: string | null): string { + if (!ua) return 'Unknown device' + let os = 'Unknown OS' + if (/Windows/i.test(ua)) os = 'Windows' + else if (/Macintosh|Mac OS/i.test(ua)) os = 'macOS' + else if (/Android/i.test(ua)) os = 'Android' + else if (/iPhone|iPad|iOS/i.test(ua)) os = 'iOS' + else if (/Linux/i.test(ua)) os = 'Linux' + let browser = '' + if (/Edg\//i.test(ua)) browser = 'Edge' + else if (/Chrome\//i.test(ua) && !/Chromium/i.test(ua)) browser = 'Chrome' + else if (/Firefox\//i.test(ua)) browser = 'Firefox' + else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari' + return browser ? `${browser} on ${os}` : os +} + function SecuritySection() { + // Change-password form + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showCurrent, setShowCurrent] = useState(false) + const [showNew, setShowNew] = useState(false) + const [changing, setChanging] = useState(false) + const [pwMsg, setPwMsg] = useState<{ text: string; ok: boolean } | null>(null) + + // Sessions + login events + const [sessions, setSessions] = useState([]) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + + async function loadActivity() { + try { + const [s, e] = await Promise.all([api.listSessions(), api.listLoginEvents(15)]) + setSessions(s.sessions) + setEvents(e.events) + } catch { + // leave existing data on transient failure + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadActivity() + }, []) + + async function handleChangePassword() { + setPwMsg(null) + if (newPassword.length < 8) { + setPwMsg({ text: 'New password must be at least 8 characters', ok: false }) + return + } + if (newPassword !== confirmPassword) { + setPwMsg({ text: 'New passwords do not match', ok: false }) + return + } + setChanging(true) + try { + await api.changePassword(currentPassword, newPassword) + setPwMsg({ text: 'Password changed. Other sessions were signed out.', ok: true }) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + loadActivity() + } catch (err) { + setPwMsg({ text: err instanceof ApiError ? err.message : 'Failed to change password', ok: false }) + } finally { + setChanging(false) + } + } + + async function handleRevoke(id: string) { + try { + await api.revokeSession(id) + setSessions((prev) => prev.filter((s) => s.id !== id)) + } catch { + loadActivity() + } + } + + const pwInputWrap: React.CSSProperties = { position: 'relative' } + const eyeBtn: React.CSSProperties = { + position: 'absolute', + right: '10px', + top: '50%', + transform: 'translateY(-50%)', + background: 'none', + border: 'none', + cursor: 'pointer', + color: '#7A7D85', + display: 'flex', + } + return ( -
-

Security

-

- Password changes, active sessions, login activity, and SSO are coming soon. -

+
+ {/* Change password */} +
+

Change Password

+
+
+ +
+ setCurrentPassword(e.target.value)} + /> + +
+
+
+ +
+ setNewPassword(e.target.value)} + /> + +
+
+
+ + setConfirmPassword(e.target.value)} + /> +
+
+ + {changing ? 'Saving…' : 'Update Password'} + + {pwMsg && ( + {pwMsg.text} + )} +
+
+
+ + {/* Active sessions */} +
+

Active Sessions

+ {loading ? ( +

Loading…

+ ) : sessions.length === 0 ? ( +

No active sessions.

+ ) : ( +
+ {sessions.map((s) => ( +
+ +
+
+ {describeUserAgent(s.userAgent)} + {s.current && ( + THIS DEVICE + )} +
+
+ {s.ip ?? 'unknown IP'} · last active {relativeTime(s.lastSeenAt)} +
+
+ {!s.current && ( + + )} +
+ ))} +
+ )} +
+ + {/* Login activity */} +
+

Recent Login Activity

+ {loading ? ( +

Loading…

+ ) : events.length === 0 ? ( +

No login activity recorded yet.

+ ) : ( +
+ {events.map((e, i) => ( +
+ +
+ + {e.success ? 'Successful login' : 'Failed login'} + + + {e.username ?? 'unknown'} · {e.ip ?? 'unknown IP'} + +
+ {relativeTime(e.createdAt)} +
+ ))} +
+ )} +

+ SSO (Authentik) and multi-user accounts are planned — see the project roadmap. +

+
) }