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:
10
server/api/admin/users.get.ts
Normal file
10
server/api/admin/users.get.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
74
server/api/admin/users.post.ts
Normal file
74
server/api/admin/users.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
78
server/api/admin/users/[id].patch.ts
Normal file
78
server/api/admin/users/[id].patch.ts
Normal 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
|
||||
}
|
||||
})
|
||||
42
server/api/admin/users/[id]/reset-password.post.ts
Normal file
42
server/api/admin/users/[id]/reset-password.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
60
server/api/auth/change-password.post.ts
Normal file
60
server/api/auth/change-password.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
46
server/api/auth/login.post.ts
Normal file
46
server/api/auth/login.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
9
server/api/auth/logout.post.ts
Normal file
9
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { destroyUserSession } from '../../utils/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await destroyUserSession(event)
|
||||
|
||||
return {
|
||||
ok: true
|
||||
}
|
||||
})
|
||||
9
server/api/auth/me.get.ts
Normal file
9
server/api/auth/me.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
17
server/api/auth/passkey/login/options.post.ts
Normal file
17
server/api/auth/passkey/login/options.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
80
server/api/auth/passkey/login/verify.post.ts
Normal file
80
server/api/auth/passkey/login/verify.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
30
server/api/auth/passkey/register/options.post.ts
Normal file
30
server/api/auth/passkey/register/options.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
74
server/api/auth/passkey/register/verify.post.ts
Normal file
74
server/api/auth/passkey/register/verify.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
11
server/api/auth/passkeys.get.ts
Normal file
11
server/api/auth/passkeys.get.ts
Normal 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
23
server/api/health.get.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
7
server/api/public/contacts.get.ts
Normal file
7
server/api/public/contacts.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { listPublicContacts } from '../../utils/user-repository'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return {
|
||||
contacts: await listPublicContacts()
|
||||
}
|
||||
})
|
||||
14
server/plugins/bootstrap.ts
Normal file
14
server/plugins/bootstrap.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ensureDatabaseReady } from '../utils/db-init'
|
||||
import { withRetry } from '../utils/retry'
|
||||
|
||||
export default defineNitroPlugin(async () => {
|
||||
await withRetry(
|
||||
() => ensureDatabaseReady(),
|
||||
{
|
||||
label: 'database bootstrap',
|
||||
retries: 15,
|
||||
delayMs: 1_000,
|
||||
factor: 1.4
|
||||
}
|
||||
)
|
||||
})
|
||||
78
server/utils/auth.ts
Normal file
78
server/utils/auth.ts
Normal 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
41
server/utils/base64url.ts
Normal 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
106
server/utils/db-init.ts
Normal 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
52
server/utils/password.ts
Normal 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
18
server/utils/postgres.ts
Normal 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
20
server/utils/redis.ts
Normal 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
39
server/utils/retry.ts
Normal 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
103
server/utils/session.ts
Normal 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: '/' })
|
||||
}
|
||||
492
server/utils/user-repository.ts
Normal file
492
server/utils/user-repository.ts
Normal 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
104
server/utils/webauthn.ts
Normal 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', ' ')}`
|
||||
}
|
||||
Reference in New Issue
Block a user