Files
product-issue/src/server/auth-service.ts
T
2026-08-24 17:27:41 +08:00

189 lines
4.9 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { AppDatabase } from './database.js'
import {
hashPassword,
keyedDigest,
randomToken,
verifyPassword
} from './security.js'
type AdminRow = {
id: string
email: string
display_name: string
password_hash: string
active: number
}
type SessionRow = {
token_hash: string
admin_id: string
csrf_token: string
expires_at: string
email: string
display_name: string
active: number
}
const dummyPasswordHash =
'scrypt$16384$8$1$ZvSK6l0GBRPxN-shgTmzUg$mAidkO9abocDjDqu3b_HX-rlkKYQAEGDRX3TKWc48doWD6Y8Ej9_Kp72M1M67kAP46vRIFXANs8kGlsMMdAXXg'
export type AuthenticatedAdmin = {
id: string
email: string
displayName: string
csrfToken: string
}
export type CreatedSession = AuthenticatedAdmin & {
token: string
expiresAt: string
}
export class AuthService {
private lastSessionPruneAt = 0
constructor(
private readonly database: AppDatabase,
private readonly secret: string,
private readonly sessionTtlMs: number
) {}
async createAdmin(input: {
email: string
displayName: string
password: string
}): Promise<void> {
const email = input.email.trim().toLowerCase()
const now = new Date().toISOString()
const passwordHash = await hashPassword(input.password)
this.database.transaction(() => {
this.database
.prepare(
`INSERT INTO admins (
id, email, display_name, password_hash, active, created_at, updated_at
) VALUES (?, ?, ?, ?, 1, ?, ?)
ON CONFLICT(email) DO UPDATE SET
display_name = excluded.display_name,
password_hash = excluded.password_hash,
active = 1,
updated_at = excluded.updated_at`
)
.run(
randomUUID(),
email,
input.displayName.trim(),
passwordHash,
now,
now
)
const admin = this.database
.prepare('SELECT id FROM admins WHERE email = ?')
.get(email) as { id: string }
this.database
.prepare('DELETE FROM admin_sessions WHERE admin_id = ?')
.run(admin.id)
})()
}
async login(email: string, password: string): Promise<CreatedSession | undefined> {
this.pruneExpiredSessionsIfDue()
const admin = this.database
.prepare(
`SELECT id, email, display_name, password_hash, active
FROM admins WHERE email = ?`
)
.get(email.trim().toLowerCase()) as AdminRow | undefined
if (!admin || admin.active !== 1) {
await verifyPassword(password, dummyPasswordHash)
return undefined
}
if (!(await verifyPassword(password, admin.password_hash))) {
return undefined
}
const token = randomToken()
const csrfToken = randomToken(24)
const now = new Date()
const expiresAt = new Date(now.getTime() + this.sessionTtlMs).toISOString()
this.database
.prepare(
`INSERT INTO admin_sessions (
token_hash, admin_id, csrf_token, expires_at, created_at
) VALUES (?, ?, ?, ?, ?)`
)
.run(
keyedDigest(this.secret, token),
admin.id,
csrfToken,
expiresAt,
now.toISOString()
)
return {
id: admin.id,
email: admin.email,
displayName: admin.display_name,
csrfToken,
token,
expiresAt
}
}
authenticate(token: string | undefined): AuthenticatedAdmin | undefined {
this.pruneExpiredSessionsIfDue()
if (!token) {
return undefined
}
const tokenHash = keyedDigest(this.secret, token)
const session = this.database
.prepare(
`SELECT
s.token_hash, s.admin_id, s.csrf_token, s.expires_at,
a.email, a.display_name, a.active
FROM admin_sessions s
JOIN admins a ON a.id = s.admin_id
WHERE s.token_hash = ?`
)
.get(tokenHash) as SessionRow | undefined
if (
!session ||
session.active !== 1 ||
Date.parse(session.expires_at) <= Date.now()
) {
if (session) {
this.database
.prepare('DELETE FROM admin_sessions WHERE token_hash = ?')
.run(tokenHash)
}
return undefined
}
return {
id: session.admin_id,
email: session.email,
displayName: session.display_name,
csrfToken: session.csrf_token
}
}
logout(token: string | undefined): void {
if (!token) {
return
}
this.database
.prepare('DELETE FROM admin_sessions WHERE token_hash = ?')
.run(keyedDigest(this.secret, token))
}
pruneExpiredSessions(now = new Date().toISOString()): void {
this.database
.prepare('DELETE FROM admin_sessions WHERE expires_at <= ?')
.run(now)
this.lastSessionPruneAt = Date.now()
}
private pruneExpiredSessionsIfDue(now = Date.now()): void {
if (now - this.lastSessionPruneAt >= 60 * 60 * 1_000) {
this.pruneExpiredSessions(new Date(now).toISOString())
}
}
}