feat: implement auth system, passkeys, and user management

Add PostgreSQL and Redis integration for users and sessions
Implement password and WebAuthn passkey login flows
Add Docker stack, super-admin seeding, and protected routes
This commit is contained in:
2026-04-12 20:16:43 +08:00
parent a649c509c2
commit 377a9617be
45 changed files with 3620 additions and 104 deletions

78
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { H3Event } from 'h3'
import type { UserRole } from '~~/shared/auth'
import { createUserSession, destroyUserSession, getUserSession } from './session'
import { getUserById, updateLastLogin, type UserAuthRecord } from './user-repository'
export function normalizeUsername(value: string) {
return value.trim().toLowerCase()
}
export async function getAuthContext(event: H3Event): Promise<{
session: Awaited<ReturnType<typeof getUserSession>>
user: UserAuthRecord
} | null> {
const session = await getUserSession(event)
if (!session) {
return null
}
const user = await getUserById(session.userId)
if (!user || !user.isActive) {
await destroyUserSession(event)
return null
}
return {
session,
user
}
}
export async function requireAuth(event: H3Event) {
const auth = await getAuthContext(event)
if (!auth) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
return auth
}
export async function requireRole(event: H3Event, role: UserRole) {
const auth = await requireAuth(event)
if (auth.user.role !== role) {
throw createError({
statusCode: 403,
statusMessage: 'You are not allowed to perform this action'
})
}
return auth
}
export async function signInUser(event: H3Event, user: UserAuthRecord, remember: boolean) {
await createUserSession(event, {
userId: user.id,
remember
})
await updateLastLogin(user.id)
const refreshedUser = await getUserById(user.id)
if (!refreshedUser) {
throw createError({
statusCode: 500,
statusMessage: 'Unable to load authenticated user'
})
}
return refreshedUser
}

41
server/utils/base64url.ts Normal file
View File

@@ -0,0 +1,41 @@
import { randomBytes } from 'node:crypto'
export function encodeBase64Url(value: Buffer | Uint8Array | string): string {
const buffer = typeof value === 'string'
? Buffer.from(value, 'utf8')
: Buffer.from(value)
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
export function decodeBase64Url(value: string): Buffer {
const normalized = value
.replace(/-/g, '+')
.replace(/_/g, '/')
const padding = normalized.length % 4 === 0
? ''
: '='.repeat(4 - (normalized.length % 4))
return Buffer.from(`${normalized}${padding}`, 'base64')
}
export function randomToken(length = 32): string {
return encodeBase64Url(randomBytes(length))
}
export function toIsoString(value: Date | string | null): string | null {
if (!value) {
return null
}
if (value instanceof Date) {
return value.toISOString()
}
return new Date(value).toISOString()
}

106
server/utils/db-init.ts Normal file
View File

@@ -0,0 +1,106 @@
import { randomUUID } from 'node:crypto'
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
import { hashPassword } from './password'
import { getSqlClient } from './postgres'
let databaseReadyPromise: Promise<void> | null = null
export async function ensureDatabaseReady() {
if (!databaseReadyPromise) {
databaseReadyPromise = initializeDatabase()
}
return databaseReadyPromise
}
async function initializeDatabase() {
const sql = getSqlClient()
await sql`
create table if not exists users (
id text primary key,
username text not null unique,
full_name text not null,
phone_number text,
role text not null check (role in ('super_admin', 'staff')),
password_hash text not null,
must_change_password boolean not null default true,
is_active boolean not null default true,
created_by text references users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
last_login_at timestamptz
)
`
await sql`
alter table users
add column if not exists phone_number text
`
await sql`
create table if not exists user_passkeys (
id text primary key,
user_id text not null references users(id) on delete cascade,
credential_id text not null unique,
public_key text not null,
counter bigint not null default 0,
device_type text not null check (device_type in ('singleDevice', 'multiDevice')),
backed_up boolean not null default false,
transports jsonb not null default '[]'::jsonb,
label text not null,
created_at timestamptz not null default now(),
last_used_at timestamptz
)
`
await sql`
create index if not exists user_passkeys_user_id_idx
on user_passkeys (user_id)
`
const [existingSuperAdmin] = await sql<{ id: string }[]>`
select id
from users
where username = 'xiaomai'
limit 1
`
if (!existingSuperAdmin) {
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
await sql`
insert into users (
id,
username,
full_name,
role,
password_hash,
must_change_password,
is_active,
created_by
)
values (
${randomUUID()},
'xiaomai',
'Xiaomai',
'super_admin',
${passwordHash},
true,
true,
null
)
`
}
await sql`
update users
set
phone_number = '601157753558',
updated_at = now()
where username = 'xiaomai'
and (phone_number is null or phone_number = '')
`
}

52
server/utils/password.ts Normal file
View File

@@ -0,0 +1,52 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import { decodeBase64Url, encodeBase64Url } from './base64url'
const scrypt = promisify(scryptCallback)
const SCRYPT_COST = 16_384
const SCRYPT_BLOCK_SIZE = 8
const SCRYPT_PARALLELIZATION = 1
const KEY_LENGTH = 64
export async function hashPassword(password: string): Promise<string> {
const salt = encodeBase64Url(randomBytes(16))
const derivedKey = await scrypt(password, salt, KEY_LENGTH, {
N: SCRYPT_COST,
r: SCRYPT_BLOCK_SIZE,
p: SCRYPT_PARALLELIZATION
}) as Buffer
return [
'scrypt',
SCRYPT_COST,
SCRYPT_BLOCK_SIZE,
SCRYPT_PARALLELIZATION,
salt,
encodeBase64Url(derivedKey)
].join('$')
}
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [algorithm, cost, blockSize, parallelization, salt, key] = storedHash.split('$')
if (
algorithm !== 'scrypt'
|| !cost
|| !blockSize
|| !parallelization
|| !salt
|| !key
) {
return false
}
const expectedKey = decodeBase64Url(key)
const derivedKey = await scrypt(password, salt, expectedKey.length, {
N: Number(cost),
r: Number(blockSize),
p: Number(parallelization)
}) as Buffer
return timingSafeEqual(expectedKey, derivedKey)
}

18
server/utils/postgres.ts Normal file
View File

@@ -0,0 +1,18 @@
import postgres from 'postgres'
let sqlClient: postgres.Sql | null = null
export function getSqlClient() {
if (!sqlClient) {
const config = useRuntimeConfig()
sqlClient = postgres(
config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system',
{
max: 10
}
)
}
return sqlClient
}

20
server/utils/redis.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createClient, type RedisClientType } from 'redis'
let redisClientPromise: Promise<RedisClientType> | null = null
export async function getRedisClient() {
if (!redisClientPromise) {
const config = useRuntimeConfig()
const client = createClient({
url: config.redisUrl || 'redis://127.0.0.1:6379'
})
client.on('error', (error) => {
console.error('Redis connection error', error)
})
redisClientPromise = client.connect().then(() => client)
}
return redisClientPromise
}

39
server/utils/retry.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface RetryOptions {
retries?: number
delayMs?: number
factor?: number
label?: string
}
function sleep(delayMs: number) {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
export async function withRetry<T>(
operation: () => Promise<T>,
options: RetryOptions = {}
) {
const retries = options.retries ?? 10
const delayMs = options.delayMs ?? 1_000
const factor = options.factor ?? 1.5
const label = options.label ?? 'operation'
let attempt = 0
let currentDelay = delayMs
while (true) {
try {
return await operation()
} catch (error) {
attempt += 1
if (attempt > retries) {
throw error
}
console.warn(`${label} failed on attempt ${attempt}. Retrying in ${currentDelay}ms.`)
await sleep(currentDelay)
currentDelay = Math.round(currentDelay * factor)
}
}
}

103
server/utils/session.ts Normal file
View File

@@ -0,0 +1,103 @@
import { randomUUID } from 'node:crypto'
import type { H3Event } from 'h3'
import { deleteCookie, getCookie, getRequestURL, setCookie } from 'h3'
import { randomToken } from './base64url'
import { getRedisClient } from './redis'
const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7
const SHORT_SESSION_TTL_SECONDS = 60 * 60 * 24
interface StoredSession {
id: string
userId: string
remember: boolean
createdAt: string
}
function getSessionCookieName() {
const config = useRuntimeConfig()
return config.sessionCookieName || 'dinner_ticket_session'
}
function getSessionStorageKey(token: string) {
return `auth:session:${token}`
}
function shouldUseSecureCookies(event: H3Event) {
const url = getRequestURL(event)
return url.protocol === 'https:'
}
function getSessionTtl(remember: boolean) {
return remember ? DEFAULT_SESSION_TTL_SECONDS : SHORT_SESSION_TTL_SECONDS
}
export async function createUserSession(event: H3Event, input: {
userId: string
remember: boolean
}) {
const token = randomToken(32)
const session: StoredSession = {
id: randomUUID(),
userId: input.userId,
remember: input.remember,
createdAt: new Date().toISOString()
}
const redis = await getRedisClient()
const ttl = getSessionTtl(input.remember)
await redis.set(getSessionStorageKey(token), JSON.stringify(session), {
expiration: {
type: 'EX',
value: ttl
}
})
setCookie(event, getSessionCookieName(), token, {
httpOnly: true,
sameSite: 'lax',
secure: shouldUseSecureCookies(event),
path: '/',
maxAge: ttl
})
return session
}
export async function getUserSession(event: H3Event): Promise<StoredSession | null> {
const token = getCookie(event, getSessionCookieName())
if (!token) {
return null
}
const redis = await getRedisClient()
const raw = await redis.get(getSessionStorageKey(token))
if (!raw) {
deleteCookie(event, getSessionCookieName(), { path: '/' })
return null
}
try {
return JSON.parse(raw) as StoredSession
} catch {
await redis.del(getSessionStorageKey(token))
deleteCookie(event, getSessionCookieName(), { path: '/' })
return null
}
}
export async function destroyUserSession(event: H3Event) {
const token = getCookie(event, getSessionCookieName())
if (token) {
const redis = await getRedisClient()
await redis.del(getSessionStorageKey(token))
}
deleteCookie(event, getSessionCookieName(), { path: '/' })
}

View File

@@ -0,0 +1,492 @@
import { randomUUID } from 'node:crypto'
import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
import { encodeBase64Url, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
import { getSqlClient } from './postgres'
type DbUserRow = {
id: string
username: string
full_name: string
phone_number: string | null
role: UserRole
password_hash: string
must_change_password: boolean
is_active: boolean
created_by: string | null
created_at: Date | string
last_login_at: Date | string | null
passkey_count: number | string
}
type DbPasskeyRow = {
id: string
user_id: string
credential_id: string
public_key: string
counter: number | string
device_type: CredentialDeviceType
backed_up: boolean
transports: AuthenticatorTransportFuture[] | string
label: string
created_at: Date | string
last_used_at: Date | string | null
}
export interface UserAuthRecord extends AuthUser {
passwordHash: string
createdBy: string | null
}
export interface PasskeyRecord {
id: string
userId: string
credentialId: string
publicKey: string
counter: number
deviceType: CredentialDeviceType
backedUp: boolean
transports: AuthenticatorTransportFuture[]
label: string
createdAt: string
lastUsedAt: string | null
}
function parseCount(value: number | string): number {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function parseCounter(value: number | string): number {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function parseTransports(value: AuthenticatorTransportFuture[] | string): AuthenticatorTransportFuture[] {
if (Array.isArray(value)) {
return value
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function mapAuthUser(row: DbUserRow): AuthUser {
const passkeyCount = parseCount(row.passkey_count)
return {
id: row.id,
username: row.username,
fullName: row.full_name,
phoneNumber: row.phone_number,
role: row.role,
isActive: row.is_active,
mustChangePassword: row.must_change_password,
needsPasskeySetup: passkeyCount === 0,
passkeyCount,
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
lastLoginAt: toIsoString(row.last_login_at)
}
}
function mapManagedUser(row: DbUserRow): ManagedUser {
return {
...mapAuthUser(row),
createdBy: row.created_by
}
}
function mapUserAuthRecord(row: DbUserRow): UserAuthRecord {
return {
...mapAuthUser(row),
passwordHash: row.password_hash,
createdBy: row.created_by
}
}
function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord {
return {
id: row.id,
userId: row.user_id,
credentialId: row.credential_id,
publicKey: row.public_key,
counter: parseCounter(row.counter),
deviceType: row.device_type,
backedUp: row.backed_up,
transports: parseTransports(row.transports),
label: row.label,
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
lastUsedAt: toIsoString(row.last_used_at)
}
}
function mapPasskeySummary(row: DbPasskeyRow): PasskeySummary {
const record = mapPasskeyRecord(row)
return {
id: record.id,
label: record.label,
createdAt: record.createdAt,
lastUsedAt: record.lastUsedAt,
deviceType: record.deviceType,
backedUp: record.backedUp
}
}
export async function getUserById(userId: string): Promise<UserAuthRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbUserRow[]>`
select
users.id,
users.username,
users.full_name,
users.phone_number,
users.role,
users.password_hash,
users.must_change_password,
users.is_active,
users.created_by,
users.created_at,
users.last_login_at,
coalesce(passkey_totals.passkey_count, 0) as passkey_count
from users
left join (
select user_id, count(*)::int as passkey_count
from user_passkeys
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
where users.id = ${userId}
limit 1
`
return row ? mapUserAuthRecord(row) : null
}
export async function getUserByUsername(username: string): Promise<UserAuthRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbUserRow[]>`
select
users.id,
users.username,
users.full_name,
users.phone_number,
users.role,
users.password_hash,
users.must_change_password,
users.is_active,
users.created_by,
users.created_at,
users.last_login_at,
coalesce(passkey_totals.passkey_count, 0) as passkey_count
from users
left join (
select user_id, count(*)::int as passkey_count
from user_passkeys
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
where users.username = ${username}
limit 1
`
return row ? mapUserAuthRecord(row) : null
}
export async function listUsers(): Promise<ManagedUser[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<DbUserRow[]>`
select
users.id,
users.username,
users.full_name,
users.phone_number,
users.role,
users.password_hash,
users.must_change_password,
users.is_active,
users.created_by,
users.created_at,
users.last_login_at,
coalesce(passkey_totals.passkey_count, 0) as passkey_count
from users
left join (
select user_id, count(*)::int as passkey_count
from user_passkeys
group by user_id
) as passkey_totals on passkey_totals.user_id = users.id
order by
case when users.role = 'super_admin' then 0 else 1 end,
users.created_at asc
`
return rows.map(mapManagedUser)
}
export async function listPublicContacts(): Promise<PublicContact[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
select
users.id,
users.full_name,
users.phone_number,
users.role
from users
where users.is_active = true
and users.phone_number is not null
and users.phone_number <> ''
order by
case when users.role = 'super_admin' then 0 else 1 end,
users.full_name asc
`
return rows.map((row) => ({
id: row.id,
fullName: row.full_name,
phoneNumber: row.phone_number || '',
role: row.role
}))
}
export async function createUser(input: {
username: string
fullName: string
phoneNumber: string
role: UserRole
passwordHash: string
createdBy: string
}): Promise<UserAuthRecord> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbUserRow[]>`
insert into users (
id,
username,
full_name,
phone_number,
role,
password_hash,
must_change_password,
is_active,
created_by
)
values (
${randomUUID()},
${input.username},
${input.fullName},
${input.phoneNumber},
${input.role},
${input.passwordHash},
true,
true,
${input.createdBy}
)
returning
id,
username,
full_name,
phone_number,
role,
password_hash,
must_change_password,
is_active,
created_by,
created_at,
last_login_at,
0::int as passkey_count
`
return mapUserAuthRecord(row)
}
export async function updateUserProfile(input: {
userId: string
fullName: string
phoneNumber: string
role: UserRole
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update users
set
full_name = ${input.fullName},
phone_number = ${input.phoneNumber},
role = ${input.role},
updated_at = now()
where id = ${input.userId}
`
return getUserById(input.userId)
}
export async function updateUserPassword(input: {
userId: string
passwordHash: string
mustChangePassword: boolean
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update users
set
password_hash = ${input.passwordHash},
must_change_password = ${input.mustChangePassword},
updated_at = now()
where id = ${input.userId}
`
}
export async function updateLastLogin(userId: string) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update users
set
last_login_at = now(),
updated_at = now()
where id = ${userId}
`
}
export async function listUserPasskeys(userId: string): Promise<PasskeySummary[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<DbPasskeyRow[]>`
select
id,
user_id,
credential_id,
public_key,
counter,
device_type,
backed_up,
transports,
label,
created_at,
last_used_at
from user_passkeys
where user_id = ${userId}
order by created_at asc
`
return rows.map(mapPasskeySummary)
}
export async function getCredentialForVerification(credentialId: string): Promise<PasskeyRecord | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbPasskeyRow[]>`
select
id,
user_id,
credential_id,
public_key,
counter,
device_type,
backed_up,
transports,
label,
created_at,
last_used_at
from user_passkeys
where credential_id = ${credentialId}
limit 1
`
return row ? mapPasskeyRecord(row) : null
}
export async function listCredentialDescriptors(userId: string) {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = await sql<Pick<DbPasskeyRow, 'credential_id' | 'transports'>[]>`
select credential_id, transports
from user_passkeys
where user_id = ${userId}
order by created_at asc
`
return rows.map((row) => ({
id: row.credential_id,
transports: parseTransports(row.transports)
}))
}
export async function createUserPasskey(input: {
userId: string
credentialId: string
publicKey: Uint8Array
counter: number
deviceType: CredentialDeviceType
backedUp: boolean
transports: AuthenticatorTransportFuture[]
label: string
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
insert into user_passkeys (
id,
user_id,
credential_id,
public_key,
counter,
device_type,
backed_up,
transports,
label
)
values (
${randomUUID()},
${input.userId},
${input.credentialId},
${encodeBase64Url(input.publicKey)},
${input.counter},
${input.deviceType},
${input.backedUp},
${sql.json(input.transports)},
${input.label}
)
`
}
export async function updateCredentialCounter(input: {
credentialId: string
counter: number
deviceType: CredentialDeviceType
backedUp: boolean
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`
update user_passkeys
set
counter = ${input.counter},
device_type = ${input.deviceType},
backed_up = ${input.backedUp},
last_used_at = now()
where credential_id = ${input.credentialId}
`
}

104
server/utils/webauthn.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { H3Event } from 'h3'
import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server'
import { getRequestURL } from 'h3'
import { decodeBase64Url, randomToken } from './base64url'
import { getRedisClient } from './redis'
import type { PasskeyRecord } from './user-repository'
const CHALLENGE_TTL_SECONDS = 60 * 5
function getAppOrigin(event: H3Event) {
const config = useRuntimeConfig()
if (config.public.appUrl) {
return new URL(config.public.appUrl).origin
}
const url = getRequestURL(event)
return `${url.protocol}//${url.host}`
}
export function getWebAuthnConfig(event: H3Event) {
const config = useRuntimeConfig()
const origin = getAppOrigin(event)
const originUrl = new URL(origin)
return {
origin,
rpID: originUrl.hostname,
rpName: config.public.rpName || 'Dinner Ticket System'
}
}
function getRegistrationChallengeKey(userId: string) {
return `webauthn:register:${userId}`
}
function getLoginChallengeKey(token: string) {
return `webauthn:login:${token}`
}
export async function storeRegistrationChallenge(userId: string, challenge: string) {
const redis = await getRedisClient()
await redis.set(getRegistrationChallengeKey(userId), challenge, {
expiration: {
type: 'EX',
value: CHALLENGE_TTL_SECONDS
}
})
}
export async function consumeRegistrationChallenge(userId: string) {
const redis = await getRedisClient()
const key = getRegistrationChallengeKey(userId)
const challenge = await redis.get(key)
if (challenge) {
await redis.del(key)
}
return challenge
}
export async function createLoginChallenge(challenge: string) {
const token = randomToken(24)
const redis = await getRedisClient()
await redis.set(getLoginChallengeKey(token), challenge, {
expiration: {
type: 'EX',
value: CHALLENGE_TTL_SECONDS
}
})
return token
}
export async function consumeLoginChallenge(token: string) {
const redis = await getRedisClient()
const key = getLoginChallengeKey(token)
const challenge = await redis.get(key)
if (challenge) {
await redis.del(key)
}
return challenge
}
export function toWebAuthnCredential(passkey: PasskeyRecord): WebAuthnCredential {
return {
id: passkey.credentialId,
publicKey: decodeBase64Url(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[]
}
}
export function buildPasskeyLabel() {
return `Passkey ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`
}