- POST /auth/forgot-password with welcome/reset email templates - POST /auth/reset-password with Zod validation, 4-hour tokens - Per-email rate limiting (3/hr) via Valkey, no user enumeration - Login page "Forgot password?" toggle with inline form - /reset-password page for setting new password from email link - Initial user seed sends welcome email instead of requiring password - CLI script for force-resetting passwords via kubectl exec - APP_URL env var in chart, removed INITIAL_USER_PASSWORD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify'
|
|
import { eq } from 'drizzle-orm'
|
|
import bcrypt from 'bcryptjs'
|
|
import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema, ForgotPasswordSchema, ResetPasswordSchema } from '@lunarfront/shared/schemas'
|
|
import { users } from '../../db/schema/users.js'
|
|
import { companies } from '../../db/schema/stores.js'
|
|
import { EmailService } from '../../services/email.service.js'
|
|
|
|
const SALT_ROUNDS = 10
|
|
|
|
export const authRoutes: FastifyPluginAsync = async (app) => {
|
|
// Rate limit auth routes — 10 attempts per 15 minutes per IP
|
|
const rateLimitConfig = {
|
|
config: {
|
|
rateLimit: {
|
|
max: 10,
|
|
timeWindow: '15 minutes',
|
|
},
|
|
},
|
|
}
|
|
|
|
app.post('/auth/register', rateLimitConfig, async (request, reply) => {
|
|
const parsed = RegisterSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
|
|
})
|
|
}
|
|
|
|
const { email, password, firstName, lastName, role } = parsed.data
|
|
|
|
// Email is globally unique
|
|
const existing = await app.db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(eq(users.email, email))
|
|
.limit(1)
|
|
|
|
if (existing.length > 0) {
|
|
return reply.status(409).send({
|
|
error: { message: 'User with this email already exists', statusCode: 409 },
|
|
})
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
|
|
|
const [user] = await app.db
|
|
.insert(users)
|
|
.values({
|
|
email,
|
|
passwordHash,
|
|
firstName,
|
|
lastName,
|
|
role,
|
|
})
|
|
.returning({
|
|
id: users.id,
|
|
email: users.email,
|
|
firstName: users.firstName,
|
|
lastName: users.lastName,
|
|
role: users.role,
|
|
createdAt: users.createdAt,
|
|
})
|
|
|
|
const token = app.jwt.sign({
|
|
id: user.id,
|
|
role: user.role,
|
|
})
|
|
|
|
request.log.info({ userId: user.id, email: user.email }, 'User registered')
|
|
return reply.status(201).send({ user, token })
|
|
})
|
|
|
|
app.post('/auth/login', rateLimitConfig, async (request, reply) => {
|
|
const parsed = LoginSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
|
|
})
|
|
}
|
|
|
|
const { email, password } = parsed.data
|
|
|
|
// Email is globally unique — company is derived from the user record
|
|
const [user] = await app.db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.email, email))
|
|
.limit(1)
|
|
|
|
if (!user) {
|
|
request.log.warn({ email }, 'Login failed — unknown email')
|
|
return reply.status(401).send({
|
|
error: { message: 'Invalid email or password', statusCode: 401 },
|
|
})
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.passwordHash)
|
|
if (!valid) {
|
|
request.log.warn({ email, userId: user.id }, 'Login failed — wrong password')
|
|
return reply.status(401).send({
|
|
error: { message: 'Invalid email or password', statusCode: 401 },
|
|
})
|
|
}
|
|
|
|
const token = app.jwt.sign({
|
|
id: user.id,
|
|
role: user.role,
|
|
})
|
|
|
|
request.log.info({ userId: user.id, email }, 'User logged in')
|
|
return reply.send({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
role: user.role,
|
|
},
|
|
token,
|
|
})
|
|
})
|
|
|
|
// Change own password
|
|
app.post('/auth/change-password', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const { currentPassword, newPassword } = request.body as { currentPassword?: string; newPassword?: string }
|
|
if (!currentPassword || !newPassword) {
|
|
return reply.status(400).send({ error: { message: 'currentPassword and newPassword are required', statusCode: 400 } })
|
|
}
|
|
if (newPassword.length < 12) {
|
|
return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } })
|
|
}
|
|
|
|
const [user] = await app.db.select().from(users).where(eq(users.id, request.user.id)).limit(1)
|
|
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
|
|
|
|
const valid = await bcrypt.compare(currentPassword, user.passwordHash)
|
|
if (!valid) {
|
|
return reply.status(401).send({ error: { message: 'Current password is incorrect', statusCode: 401 } })
|
|
}
|
|
|
|
const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS)
|
|
await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, request.user.id))
|
|
|
|
request.log.info({ userId: request.user.id }, 'Password changed')
|
|
return reply.send({ message: 'Password changed' })
|
|
})
|
|
|
|
// Admin: generate password reset token for a user
|
|
app.post('/auth/reset-password/:userId', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => {
|
|
const { userId } = request.params as { userId: string }
|
|
|
|
const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1)
|
|
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
|
|
|
|
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
|
|
const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}`
|
|
|
|
request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated')
|
|
return reply.send({ resetLink, expiresIn: '4 hours' })
|
|
})
|
|
|
|
// Reset password with token
|
|
app.post('/auth/reset-password', async (request, reply) => {
|
|
const parsed = ResetPasswordSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
|
}
|
|
|
|
const { token, newPassword } = parsed.data
|
|
|
|
try {
|
|
const payload = app.jwt.verify<{ userId: string; purpose: string }>(token)
|
|
if (payload.purpose !== 'password-reset') {
|
|
return reply.status(400).send({ error: { message: 'Invalid reset token', statusCode: 400 } })
|
|
}
|
|
|
|
const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS)
|
|
await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, payload.userId))
|
|
|
|
request.log.info({ userId: payload.userId }, 'Password reset via token')
|
|
return reply.send({ message: 'Password reset successfully' })
|
|
} catch {
|
|
return reply.status(400).send({ error: { message: 'Invalid or expired reset token', statusCode: 400 } })
|
|
}
|
|
})
|
|
|
|
// Forgot password / resend welcome — public, always returns success (no user enumeration)
|
|
// Pass ?type=welcome for welcome emails, defaults to reset
|
|
app.post('/auth/forgot-password', rateLimitConfig, async (request, reply) => {
|
|
const parsed = ForgotPasswordSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
|
}
|
|
|
|
const { email } = parsed.data
|
|
const isWelcome = (request.query as { type?: string }).type === 'welcome'
|
|
|
|
// Rate limit per email — max 3 emails per hour
|
|
const emailKey = `pwd-reset:${email.toLowerCase()}`
|
|
const count = await app.redis.incr(emailKey)
|
|
if (count === 1) await app.redis.expire(emailKey, 3600)
|
|
if (count > 3) {
|
|
return reply.send({ message: 'If an account exists with that email, you will receive an email.' })
|
|
}
|
|
|
|
// Always return success — don't reveal whether the email exists
|
|
const [user] = await app.db.select({ id: users.id, firstName: users.firstName }).from(users).where(eq(users.email, email)).limit(1)
|
|
|
|
if (user) {
|
|
try {
|
|
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
|
|
const appUrl = process.env.APP_URL ?? 'http://localhost:5173'
|
|
const resetLink = `${appUrl}/reset-password?token=${resetToken}`
|
|
|
|
const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1)
|
|
const storeName = store?.name ?? 'LunarFront'
|
|
|
|
if (isWelcome) {
|
|
await EmailService.send(app.db, {
|
|
to: email,
|
|
subject: `Welcome to ${storeName} — Set your password`,
|
|
html: `
|
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
|
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
|
|
<p style="color: #555; margin-bottom: 24px;">Hi ${user.firstName},</p>
|
|
<p style="color: #555;">Your account has been created. Click the button below to set your password and get started:</p>
|
|
<div style="text-align: center; margin: 32px 0;">
|
|
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Set Your Password</a>
|
|
</div>
|
|
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If it expires, you can request a new one from the login page.</p>
|
|
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
|
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
|
|
</div>
|
|
`,
|
|
text: `Hi ${user.firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`,
|
|
})
|
|
} else {
|
|
await EmailService.send(app.db, {
|
|
to: email,
|
|
subject: `Reset your password — ${storeName}`,
|
|
html: `
|
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
|
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
|
|
<p style="color: #555; margin-bottom: 24px;">Hi ${user.firstName},</p>
|
|
<p style="color: #555;">We received a request to reset your password. Click the button below to choose a new one:</p>
|
|
<div style="text-align: center; margin: 32px 0;">
|
|
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Reset Password</a>
|
|
</div>
|
|
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If you didn't request this, you can safely ignore this email.</p>
|
|
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
|
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
|
|
</div>
|
|
`,
|
|
text: `Hi ${user.firstName}, reset your password here: ${resetLink} — This link expires in 4 hours.`,
|
|
})
|
|
}
|
|
|
|
request.log.info({ userId: user.id, type: isWelcome ? 'welcome' : 'reset' }, 'Password email sent')
|
|
} catch (err) {
|
|
request.log.error({ email, error: (err as Error).message }, 'Failed to send password email')
|
|
}
|
|
}
|
|
|
|
return reply.send({ message: 'If an account exists with that email, you will receive a password reset link.' })
|
|
})
|
|
|
|
// Get current user profile
|
|
app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const [user] = await app.db
|
|
.select({
|
|
id: users.id,
|
|
email: users.email,
|
|
firstName: users.firstName,
|
|
lastName: users.lastName,
|
|
role: users.role,
|
|
createdAt: users.createdAt,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, request.user.id))
|
|
.limit(1)
|
|
|
|
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
|
|
return reply.send(user)
|
|
})
|
|
|
|
// Update current user profile
|
|
app.patch('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const { firstName, lastName } = request.body as { firstName?: string; lastName?: string }
|
|
|
|
const updates: Record<string, unknown> = { updatedAt: new Date() }
|
|
if (firstName) updates.firstName = firstName
|
|
if (lastName) updates.lastName = lastName
|
|
|
|
const [user] = await app.db
|
|
.update(users)
|
|
.set(updates)
|
|
.where(eq(users.id, request.user.id))
|
|
.returning({
|
|
id: users.id,
|
|
email: users.email,
|
|
firstName: users.firstName,
|
|
lastName: users.lastName,
|
|
role: users.role,
|
|
})
|
|
|
|
return reply.send(user)
|
|
})
|
|
|
|
// PIN login — for POS unlock, no JWT required to call
|
|
app.post('/auth/pin-login', rateLimitConfig, async (request, reply) => {
|
|
const parsed = PinLoginSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
|
}
|
|
|
|
const { code } = parsed.data
|
|
// First 4 digits = employee number, rest = PIN
|
|
const employeeNumber = code.slice(0, 4)
|
|
const pin = code.slice(4)
|
|
|
|
if (!pin) {
|
|
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
|
|
}
|
|
|
|
const [user] = await app.db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.employeeNumber, employeeNumber))
|
|
.limit(1)
|
|
|
|
if (!user || !user.isActive || !user.pinHash) {
|
|
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
|
|
}
|
|
|
|
const match = await bcrypt.compare(pin, user.pinHash)
|
|
if (!match) {
|
|
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
|
|
}
|
|
|
|
const token = app.jwt.sign({ id: user.id, role: user.role }, { expiresIn: '8h' })
|
|
request.log.info({ userId: user.id }, 'PIN login')
|
|
return reply.send({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
role: user.role,
|
|
},
|
|
token,
|
|
})
|
|
})
|
|
|
|
// Set PIN — requires full auth
|
|
app.post('/auth/set-pin', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const parsed = SetPinSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
|
}
|
|
|
|
const pinHash = await bcrypt.hash(parsed.data.pin, SALT_ROUNDS)
|
|
await app.db.update(users).set({ pinHash, updatedAt: new Date() }).where(eq(users.id, request.user.id))
|
|
|
|
request.log.info({ userId: request.user.id }, 'POS PIN set')
|
|
return reply.send({ message: 'PIN set' })
|
|
})
|
|
|
|
// Remove PIN — requires full auth
|
|
app.delete('/auth/pin', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
await app.db.update(users).set({ pinHash: null, updatedAt: new Date() }).where(eq(users.id, request.user.id))
|
|
|
|
request.log.info({ userId: request.user.id }, 'POS PIN removed')
|
|
return reply.send({ message: 'PIN removed' })
|
|
})
|
|
}
|