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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user