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

View File

@@ -0,0 +1,10 @@
import { requireRole } from '../../utils/auth'
import { listUsers } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
await requireRole(event, 'super_admin')
return {
users: await listUsers()
}
})

View File

@@ -0,0 +1,74 @@
import {
DEFAULT_USER_PASSWORD,
USERNAME_PATTERN,
isValidPhoneNumber,
normalizePhoneNumber,
type UserRole
} from '~~/shared/auth'
import { normalizeUsername, requireRole } from '../../utils/auth'
import { hashPassword } from '../../utils/password'
import { createUser } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireRole(event, 'super_admin')
const body = await readBody<{
username?: string
fullName?: string
phoneNumber?: string
role?: UserRole
}>(event)
const username = normalizeUsername(body.username || '')
const fullName = body.fullName?.trim() || ''
const phoneNumber = normalizePhoneNumber(body.phoneNumber || '')
const role = body.role === 'super_admin' ? 'super_admin' : 'staff'
if (!USERNAME_PATTERN.test(username)) {
throw createError({
statusCode: 400,
statusMessage: 'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore'
})
}
if (fullName.length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'Full name must be at least 2 characters'
})
}
if (!isValidPhoneNumber(phoneNumber)) {
throw createError({
statusCode: 400,
statusMessage: 'Phone number must contain 8 to 15 digits'
})
}
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
try {
const user = await createUser({
username,
fullName,
phoneNumber,
role,
passwordHash,
createdBy: auth.user.id
})
return {
user,
defaultPassword: DEFAULT_USER_PASSWORD
}
} catch (error: any) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
statusMessage: 'Username already exists'
})
}
throw error
}
})

View File

@@ -0,0 +1,78 @@
import {
isValidPhoneNumber,
normalizePhoneNumber,
type UserRole
} from '~~/shared/auth'
import { requireRole } from '../../../utils/auth'
import { getUserById, updateUserProfile } from '../../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireRole(event, 'super_admin')
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: 'User id is required'
})
}
const body = await readBody<{
fullName?: string
phoneNumber?: string
role?: UserRole
}>(event)
const fullName = body.fullName?.trim() || ''
const phoneNumber = normalizePhoneNumber(body.phoneNumber || '')
const role = body.role
if (fullName.length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'Display name must be at least 2 characters'
})
}
if (!isValidPhoneNumber(phoneNumber)) {
throw createError({
statusCode: 400,
statusMessage: 'Phone number must contain 8 to 15 digits'
})
}
if (role !== 'super_admin' && role !== 'staff') {
throw createError({
statusCode: 400,
statusMessage: 'Role is invalid'
})
}
const user = await getUserById(userId)
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found'
})
}
if (auth.user.id === userId && role !== 'super_admin') {
throw createError({
statusCode: 400,
statusMessage: 'You cannot remove your own super admin access'
})
}
const updatedUser = await updateUserProfile({
userId,
fullName,
phoneNumber,
role
})
return {
user: updatedUser
}
})

View File

@@ -0,0 +1,42 @@
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
import { requireRole } from '../../../../utils/auth'
import { hashPassword } from '../../../../utils/password'
import { getUserById, updateUserPassword } from '../../../../utils/user-repository'
export default defineEventHandler(async (event) => {
await requireRole(event, 'super_admin')
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: 'User id is required'
})
}
const user = await getUserById(userId)
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found'
})
}
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
await updateUserPassword({
userId,
passwordHash,
mustChangePassword: true
})
const updatedUser = await getUserById(userId)
return {
user: updatedUser,
defaultPassword: DEFAULT_USER_PASSWORD
}
})

View File

@@ -0,0 +1,60 @@
import { MIN_PASSWORD_LENGTH } from '~~/shared/auth'
import { requireAuth } from '../../utils/auth'
import { hashPassword, verifyPassword } from '../../utils/password'
import { getUserById, updateUserPassword } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const body = await readBody<{
currentPassword?: string
newPassword?: string
}>(event)
const currentPassword = body.currentPassword?.trim() || ''
const newPassword = body.newPassword?.trim() || ''
if (!currentPassword || !newPassword) {
throw createError({
statusCode: 400,
statusMessage: 'Current password and new password are required'
})
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw createError({
statusCode: 400,
statusMessage: `New password must be at least ${MIN_PASSWORD_LENGTH} characters`
})
}
if (currentPassword === newPassword) {
throw createError({
statusCode: 400,
statusMessage: 'New password must be different from the current password'
})
}
const currentPasswordMatches = await verifyPassword(currentPassword, auth.user.passwordHash)
if (!currentPasswordMatches) {
throw createError({
statusCode: 400,
statusMessage: 'Current password is incorrect'
})
}
const passwordHash = await hashPassword(newPassword)
await updateUserPassword({
userId: auth.user.id,
passwordHash,
mustChangePassword: false
})
const updatedUser = await getUserById(auth.user.id)
return {
user: updatedUser
}
})

View File

@@ -0,0 +1,46 @@
import { verifyPassword } from '../../utils/password'
import { normalizeUsername, signInUser } from '../../utils/auth'
import { getUserByUsername } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const body = await readBody<{
username?: string
password?: string
remember?: boolean
}>(event)
const username = normalizeUsername(body.username || '')
const password = body.password?.trim() || ''
const remember = body.remember !== false
if (!username || !password) {
throw createError({
statusCode: 400,
statusMessage: 'Username and password are required'
})
}
const user = await getUserByUsername(username)
if (!user || !user.isActive) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid username or password'
})
}
const passwordMatches = await verifyPassword(password, user.passwordHash)
if (!passwordMatches) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid username or password'
})
}
const authenticatedUser = await signInUser(event, user, remember)
return {
user: authenticatedUser
}
})

View File

@@ -0,0 +1,9 @@
import { destroyUserSession } from '../../utils/session'
export default defineEventHandler(async (event) => {
await destroyUserSession(event)
return {
ok: true
}
})

View File

@@ -0,0 +1,9 @@
import { getAuthContext } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event)
return {
user: auth?.user ?? null
}
})

View File

@@ -0,0 +1,17 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { createLoginChallenge, getWebAuthnConfig } from '../../../../utils/webauthn'
export default defineEventHandler(async (event) => {
const config = getWebAuthnConfig(event)
const options = await generateAuthenticationOptions({
rpID: config.rpID,
userVerification: 'preferred'
})
const challengeToken = await createLoginChallenge(options.challenge)
return {
options,
challengeToken
}
})

View File

@@ -0,0 +1,80 @@
import { verifyAuthenticationResponse, type AuthenticationResponseJSON } from '@simplewebauthn/server'
import { getUserById, getCredentialForVerification, updateCredentialCounter } from '../../../../utils/user-repository'
import { consumeLoginChallenge, getWebAuthnConfig, toWebAuthnCredential } from '../../../../utils/webauthn'
import { signInUser } from '../../../../utils/auth'
export default defineEventHandler(async (event) => {
const body = await readBody<{
response?: AuthenticationResponseJSON
challengeToken?: string
remember?: boolean
}>(event)
const response = body.response
const challengeToken = body.challengeToken?.trim() || ''
const remember = body.remember !== false
if (!response || !challengeToken) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey login payload is incomplete'
})
}
const expectedChallenge = await consumeLoginChallenge(challengeToken)
if (!expectedChallenge) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey login challenge expired. Try again.'
})
}
const storedCredential = await getCredentialForVerification(response.id)
if (!storedCredential) {
throw createError({
statusCode: 401,
statusMessage: 'Passkey is not recognized'
})
}
const config = getWebAuthnConfig(event)
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
credential: toWebAuthnCredential(storedCredential)
})
if (!verification.verified) {
throw createError({
statusCode: 401,
statusMessage: 'Passkey authentication failed'
})
}
const user = await getUserById(storedCredential.userId)
if (!user || !user.isActive) {
throw createError({
statusCode: 401,
statusMessage: 'User account is not available'
})
}
await updateCredentialCounter({
credentialId: storedCredential.credentialId,
counter: verification.authenticationInfo.newCounter,
deviceType: verification.authenticationInfo.credentialDeviceType,
backedUp: verification.authenticationInfo.credentialBackedUp
})
const authenticatedUser = await signInUser(event, user, remember)
return {
user: authenticatedUser
}
})

View File

@@ -0,0 +1,30 @@
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { requireAuth } from '../../../../utils/auth'
import { listCredentialDescriptors } from '../../../../utils/user-repository'
import { getWebAuthnConfig, storeRegistrationChallenge } from '../../../../utils/webauthn'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const config = getWebAuthnConfig(event)
const excludeCredentials = await listCredentialDescriptors(auth.user.id)
const options = await generateRegistrationOptions({
rpName: config.rpName,
rpID: config.rpID,
userName: auth.user.username,
userDisplayName: auth.user.fullName,
userID: Buffer.from(auth.user.id),
excludeCredentials,
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
preferredAuthenticatorType: 'localDevice'
})
await storeRegistrationChallenge(auth.user.id, options.challenge)
return {
options
}
})

View File

@@ -0,0 +1,74 @@
import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server'
import { requireAuth } from '../../../../utils/auth'
import { createUserPasskey, getUserById, listUserPasskeys } from '../../../../utils/user-repository'
import { buildPasskeyLabel, consumeRegistrationChallenge, getWebAuthnConfig } from '../../../../utils/webauthn'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const body = await readBody<{
response?: RegistrationResponseJSON
}>(event)
if (!body.response) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey registration response is required'
})
}
const expectedChallenge = await consumeRegistrationChallenge(auth.user.id)
if (!expectedChallenge) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey registration challenge expired. Try again.'
})
}
const config = getWebAuthnConfig(event)
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID
})
if (!verification.verified || !verification.registrationInfo) {
throw createError({
statusCode: 400,
statusMessage: 'Passkey registration could not be verified'
})
}
try {
await createUserPasskey({
userId: auth.user.id,
credentialId: verification.registrationInfo.credential.id,
publicKey: verification.registrationInfo.credential.publicKey,
counter: verification.registrationInfo.credential.counter,
deviceType: verification.registrationInfo.credentialDeviceType,
backedUp: verification.registrationInfo.credentialBackedUp,
transports: body.response.response.transports || [],
label: buildPasskeyLabel()
})
} catch (error: any) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
statusMessage: 'This passkey is already registered'
})
}
throw error
}
const updatedUser = await getUserById(auth.user.id)
const passkeys = await listUserPasskeys(auth.user.id)
return {
ok: true,
user: updatedUser,
passkeys
}
})

View File

@@ -0,0 +1,11 @@
import { requireAuth } from '../../utils/auth'
import { listUserPasskeys } from '../../utils/user-repository'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const passkeys = await listUserPasskeys(auth.user.id)
return {
passkeys
}
})

23
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ensureDatabaseReady } from '../utils/db-init'
import { getRedisClient } from '../utils/redis'
import { getSqlClient } from '../utils/postgres'
export default defineEventHandler(async () => {
await ensureDatabaseReady()
const sql = getSqlClient()
await sql`select 1`
const redis = await getRedisClient()
await redis.ping()
return {
ok: true,
services: {
app: 'up',
postgres: 'up',
redis: 'up'
},
timestamp: new Date().toISOString()
}
})

View File

@@ -0,0 +1,7 @@
import { listPublicContacts } from '../../utils/user-repository'
export default defineEventHandler(async () => {
return {
contacts: await listPublicContacts()
}
})