Add auth Phase 2: password change, sessions, and login audit log (#27)

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 <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
Samuel James 2026-06-20 11:50:56 -04:00 committed by GitHub
parent 993792e193
commit 2ccc7b82d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 451 additions and 12 deletions

View file

@ -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) {

View file

@ -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,
})),
}
})
}

View file

@ -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)
}
})

View file

@ -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 }
}
}

View file

@ -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')

View file

@ -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<string, string>; secrets?: Record<string, string> }) =>
@ -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

View file

@ -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,14 +1259,276 @@ 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<AuthSession[]>([])
const [events, setEvents] = useState<LoginEvent[]>([])
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 (
<div className="flex flex-col gap-5">
{/* Change password */}
<div style={cardBase}>
<h3 style={sectionTitle}>Security</h3>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
Password changes, active sessions, login activity, and SSO are coming soon.
<h3 style={sectionTitle}>Change Password</h3>
<div className="flex flex-col gap-4" style={{ maxWidth: '420px' }}>
<div>
<label style={labelStyle}>Current Password</label>
<div style={pwInputWrap}>
<input
style={{ ...inputStyle, paddingRight: '38px' }}
type={showCurrent ? 'text' : 'password'}
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<button style={eyeBtn} onClick={() => setShowCurrent((v) => !v)} type="button" title={showCurrent ? 'Hide' : 'Show'}>
{showCurrent ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<div>
<label style={labelStyle}>New Password</label>
<div style={pwInputWrap}>
<input
style={{ ...inputStyle, paddingRight: '38px' }}
type={showNew ? 'text' : 'password'}
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<button style={eyeBtn} onClick={() => setShowNew((v) => !v)} type="button" title={showNew ? 'Hide' : 'Show'}>
{showNew ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<div>
<label style={labelStyle}>Confirm New Password</label>
<input
style={inputStyle}
type={showNew ? 'text' : 'password'}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<GoldButton
onClick={handleChangePassword}
disabled={changing || !currentPassword || !newPassword || !confirmPassword}
>
{changing ? 'Saving…' : 'Update Password'}
</GoldButton>
{pwMsg && (
<span style={{ fontSize: '12px', color: pwMsg.ok ? '#2ECC71' : '#E74C3C' }}>{pwMsg.text}</span>
)}
</div>
</div>
</div>
{/* Active sessions */}
<div style={cardBase}>
<h3 style={sectionTitle}>Active Sessions</h3>
{loading ? (
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading</p>
) : sessions.length === 0 ? (
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No active sessions.</p>
) : (
<div className="flex flex-col gap-2">
{sessions.map((s) => (
<div
key={s.id}
className="flex items-center gap-3"
style={{
padding: '12px 14px',
borderRadius: '8px',
border: '1px solid rgba(200,164,52,0.08)',
backgroundColor: 'rgba(255,255,255,0.02)',
}}
>
<Monitor size={18} color={s.current ? '#C8A434' : '#7A7D85'} style={{ flexShrink: 0 }} />
<div className="flex-1 min-w-0">
<div style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>
{describeUserAgent(s.userAgent)}
{s.current && (
<span style={{ fontSize: '10px', color: '#C8A434', marginLeft: '8px', fontWeight: 600 }}>THIS DEVICE</span>
)}
</div>
<div style={{ fontSize: '11px', color: '#7A7D85' }}>
{s.ip ?? 'unknown IP'} · last active {relativeTime(s.lastSeenAt)}
</div>
</div>
{!s.current && (
<button
onClick={() => handleRevoke(s.id)}
className="flex items-center gap-1.5 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '11px',
fontWeight: 600,
color: '#E74C3C',
background: 'transparent',
border: '1px solid rgba(231,76,60,0.4)',
borderRadius: '7px',
padding: '6px 12px',
}}
>
<LogOut size={13} /> Sign out
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Login activity */}
<div style={cardBase}>
<h3 style={sectionTitle}>Recent Login Activity</h3>
{loading ? (
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading</p>
) : events.length === 0 ? (
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No login activity recorded yet.</p>
) : (
<div className="flex flex-col">
{events.map((e, i) => (
<div
key={e.id}
className="flex items-center gap-3"
style={{
padding: '10px 0',
borderTop: i === 0 ? 'none' : '1px solid rgba(200,164,52,0.06)',
}}
>
<span
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: e.success ? '#2ECC71' : '#E74C3C',
flexShrink: 0,
}}
/>
<div className="flex-1 min-w-0">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>
{e.success ? 'Successful login' : 'Failed login'}
</span>
<span style={{ fontSize: '11px', color: '#7A7D85', marginLeft: '8px' }}>
{e.username ?? 'unknown'} · {e.ip ?? 'unknown IP'}
</span>
</div>
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{relativeTime(e.createdAt)}</span>
</div>
))}
</div>
)}
<p style={{ fontSize: '11px', color: '#7A7D85', marginTop: '14px' }}>
SSO (Authentik) and multi-user accounts are planned see the project roadmap.
</p>
</div>
</div>
)
}