feat: password reset flow with welcome emails
- 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>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema } from '@lunarfront/shared/schemas'
|
||||
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
|
||||
|
||||
@@ -151,24 +153,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
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 } })
|
||||
|
||||
// Generate a signed reset token that expires in 1 hour
|
||||
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '1h' })
|
||||
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: '1 hour' })
|
||||
return reply.send({ resetLink, expiresIn: '4 hours' })
|
||||
})
|
||||
|
||||
// Reset password with token
|
||||
app.post('/auth/reset-password', async (request, reply) => {
|
||||
const { token, newPassword } = request.body as { token?: string; newPassword?: string }
|
||||
if (!token || !newPassword) {
|
||||
return reply.status(400).send({ error: { message: 'token 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 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') {
|
||||
@@ -185,6 +185,86 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user