189 lines
4.9 KiB
TypeScript
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())
|
|
}
|
|
}
|
|
}
|