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