Initial commit
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user