-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add forgot password & reset password flow #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -180,3 +180,17 @@ CREATE TABLE page_media ( | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| CREATE INDEX idx_page_media_page ON page_media(page); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| -- Password reset tokens | ||||||||||||||||||||||||||||||
| CREATE TABLE password_reset_tokens ( | ||||||||||||||||||||||||||||||
| id BIGSERIAL PRIMARY KEY, | ||||||||||||||||||||||||||||||
| user_id BIGINT NOT NULL, | ||||||||||||||||||||||||||||||
| token_hash VARCHAR(128) NOT NULL, | ||||||||||||||||||||||||||||||
| expires_at TIMESTAMP WITH TIME ZONE NOT NULL, | ||||||||||||||||||||||||||||||
| used BOOLEAN DEFAULT FALSE, | ||||||||||||||||||||||||||||||
| created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | ||||||||||||||||||||||||||||||
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash); | ||||||||||||||||||||||||||||||
| CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
Comment on lines
182
to
+196
|
||||||||||||||||||||||||||||||
| -- Password reset tokens | |
| CREATE TABLE password_reset_tokens ( | |
| id BIGSERIAL PRIMARY KEY, | |
| user_id BIGINT NOT NULL, | |
| token_hash VARCHAR(128) NOT NULL, | |
| expires_at TIMESTAMP WITH TIME ZONE NOT NULL, | |
| used BOOLEAN DEFAULT FALSE, | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | |
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | |
| ); | |
| CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash); | |
| CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import crypto from 'crypto' | ||
| import { dbOneOrNone, dbNone } from 'src/lib/database' | ||
| import { sendMail } from 'src/lib/mailer' | ||
| import rateLimit from 'src/lib/rateLimit' | ||
|
|
||
| const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }) | ||
|
|
||
| /** | ||
| * POST /api/auth/forgot-password | ||
| * | ||
| * Generates a secure password-reset token, stores its SHA-256 hash, | ||
| * and emails the raw token to the user. Always returns 200 to avoid | ||
| * leaking whether an email exists. | ||
| * | ||
| * Body: { email: string } | ||
| */ | ||
| export default async function handler(req, res) { | ||
| if (req.method !== 'POST') { | ||
| return res.status(405).json({ error: 'Method not allowed' }) | ||
| } | ||
|
|
||
| // Rate-limit by IP | ||
| const { ok } = limiter.check(req) | ||
| if (!ok) { | ||
| return res.status(429).json({ error: 'Too many requests. Please try again later.' }) | ||
| } | ||
|
|
||
| const { email } = req.body | ||
|
|
||
| if (!email || typeof email !== 'string') { | ||
| return res.status(400).json({ error: 'Email is required' }) | ||
| } | ||
|
|
||
| const trimmedEmail = email.trim().toLowerCase() | ||
| const emailRegex = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/ | ||
| if (trimmedEmail.length > 254 || !emailRegex.test(trimmedEmail)) { | ||
| return res.status(400).json({ error: 'Invalid email format' }) | ||
| } | ||
|
|
||
| try { | ||
| // Always respond with success to avoid email enumeration | ||
| const successMsg = 'If an account with that email exists, a password reset link has been sent.' | ||
|
|
||
| const user = await dbOneOrNone( | ||
| 'SELECT id, name FROM users WHERE LOWER(email) = $1', | ||
| [trimmedEmail] | ||
| ) | ||
|
|
||
| if (!user) { | ||
| return res.status(200).json({ message: successMsg }) | ||
| } | ||
|
|
||
| // Invalidate any existing unused tokens for this user | ||
| await dbNone( | ||
| 'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND used = FALSE', | ||
| [user.id] | ||
| ) | ||
|
|
||
| // Generate a cryptographically secure token | ||
| const rawToken = crypto.randomBytes(32).toString('hex') | ||
|
|
||
| // Store only the SHA-256 hash in DB | ||
| const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex') | ||
|
|
||
| // Token expires in 1 hour | ||
| const expiresAt = new Date(Date.now() + 60 * 60 * 1000) | ||
|
|
||
| await dbNone( | ||
| 'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', | ||
| [user.id, tokenHash, expiresAt] | ||
| ) | ||
|
Comment on lines
+53
to
+71
|
||
|
|
||
| // Build reset URL | ||
| const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' | ||
| const resetUrl = `${baseUrl}/reset-password?token=${rawToken}` | ||
|
|
||
| // Send email | ||
| await sendMail({ | ||
| to: trimmedEmail, | ||
| subject: 'Reset your Citronics 2026 password', | ||
| html: buildResetEmailHtml(user.name, resetUrl), | ||
| text: `Hi ${user.name},\n\nYou requested a password reset. Use this link (valid for 1 hour):\n\n${resetUrl}\n\nIf you didn't request this, ignore this email.\n\nCitronics 2026` | ||
| }) | ||
|
|
||
| return res.status(200).json({ message: successMsg }) | ||
| } catch (err) { | ||
| console.error('[ForgotPassword] Error:', err) | ||
|
|
||
| return res.status(500).json({ error: 'Something went wrong. Please try again later.' }) | ||
| } | ||
|
Comment on lines
+86
to
+90
|
||
| } | ||
|
|
||
| function buildResetEmailHtml(name, resetUrl) { | ||
| return ` | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /></head> | ||
| <body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> | ||
| <table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:40px 20px;"> | ||
| <tr><td align="center"> | ||
| <table width="100%" style="max-width:480px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);"> | ||
| <tr><td style="background:linear-gradient(135deg,#7c3aed,#4f46e5);padding:28px 32px;"> | ||
| <h1 style="margin:0;color:#ffffff;font-size:20px;font-weight:700;">Citronics 2026</h1> | ||
| </td></tr> | ||
| <tr><td style="padding:32px;"> | ||
| <h2 style="margin:0 0 12px;font-size:18px;color:#1a1a2e;">Password Reset</h2> | ||
| <p style="margin:0 0 20px;font-size:14px;line-height:1.6;color:#555;"> | ||
| Hi <strong>${name}</strong>, we received a request to reset your password. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape user-provided The Proposed fix: Add HTML escaping helper+function escapeHtml(str) {
+ return str
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+}
+
function buildResetEmailHtml(name, resetUrl) {
+ const safeName = escapeHtml(name)
return `
...
- Hi <strong>${name}</strong>, we received a request to reset your password.
+ Hi <strong>${safeName}</strong>, we received a request to reset your password.🤖 Prompt for AI Agents |
||
| Click the button below to choose a new one. This link expires in <strong>1 hour</strong>. | ||
| </p> | ||
|
Comment on lines
+106
to
+110
|
||
| <table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"> | ||
| <tr><td style="background:#7c3aed;border-radius:8px;"> | ||
| <a href="${resetUrl}" target="_blank" | ||
| style="display:inline-block;padding:12px 28px;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;"> | ||
| Reset Password | ||
| </a> | ||
| </td></tr> | ||
| </table> | ||
| <p style="margin:0 0 8px;font-size:12px;color:#888;">If the button doesn’t work, copy and paste this URL:</p> | ||
| <p style="margin:0 0 24px;font-size:12px;color:#7c3aed;word-break:break-all;">${resetUrl}</p> | ||
| <hr style="border:none;border-top:1px solid #eee;margin:0 0 16px;" /> | ||
| <p style="margin:0;font-size:12px;color:#999;line-height:1.5;"> | ||
| If you didn’t request this, you can safely ignore this email. Your password won’t change. | ||
| </p> | ||
| </td></tr> | ||
| </table> | ||
| </td></tr> | ||
| </table> | ||
| </body> | ||
| </html>` | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import crypto from 'crypto' | ||
| import bcrypt from 'bcryptjs' | ||
| import { dbOneOrNone, dbTx } from 'src/lib/database' | ||
| import rateLimit from 'src/lib/rateLimit' | ||
|
|
||
| const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }) | ||
|
|
||
| /** | ||
| * POST /api/auth/reset-password | ||
| * | ||
| * Validates the reset token, checks expiry, and updates the user's password. | ||
| * The raw token is hashed with SHA-256 and compared against the stored hash. | ||
| * | ||
| * Body: { token: string, password: string } | ||
| */ | ||
| export default async function handler(req, res) { | ||
| if (req.method !== 'POST') { | ||
| return res.status(405).json({ error: 'Method not allowed' }) | ||
| } | ||
|
|
||
| const { ok } = limiter.check(req) | ||
| if (!ok) { | ||
| return res.status(429).json({ error: 'Too many requests. Please try again later.' }) | ||
| } | ||
|
|
||
| const { token, password } = req.body | ||
|
|
||
| if (!token || typeof token !== 'string') { | ||
| return res.status(400).json({ error: 'Reset token is required' }) | ||
| } | ||
|
|
||
| if (!password || password.length < 6) { | ||
| return res.status(400).json({ error: 'Password must be at least 6 characters' }) | ||
| } | ||
|
|
||
| try { | ||
| const tokenHash = crypto.createHash('sha256').update(token).digest('hex') | ||
|
|
||
| const resetRecord = await dbOneOrNone( | ||
| `SELECT prt.id, prt.user_id, prt.expires_at, prt.used, u.email | ||
| FROM password_reset_tokens prt | ||
| JOIN users u ON u.id = prt.user_id | ||
| WHERE prt.token_hash = $1`, | ||
| [tokenHash] | ||
| ) | ||
|
|
||
| if (!resetRecord) { | ||
| return res.status(400).json({ error: 'Invalid or expired reset link. Please request a new one.' }) | ||
| } | ||
|
|
||
| if (resetRecord.used) { | ||
| return res.status(400).json({ error: 'This reset link has already been used. Please request a new one.' }) | ||
| } | ||
|
|
||
| if (new Date(resetRecord.expires_at) < new Date()) { | ||
| return res.status(400).json({ error: 'This reset link has expired. Please request a new one.' }) | ||
| } | ||
|
|
||
| // Hash the new password | ||
| const passwordHash = await bcrypt.hash(password, 12) | ||
|
|
||
| // Update password & mark token used in a transaction | ||
| await dbTx(async t => { | ||
| await t.none( | ||
| 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', | ||
| [passwordHash, resetRecord.user_id] | ||
| ) | ||
| await t.none( | ||
| 'UPDATE password_reset_tokens SET used = TRUE WHERE id = $1', | ||
| [resetRecord.id] | ||
| ) | ||
| // Invalidate all other unused tokens for this user | ||
| await t.none( | ||
| 'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND id != $2 AND used = FALSE', | ||
| [resetRecord.user_id, resetRecord.id] | ||
| ) | ||
|
Comment on lines
+39
to
+76
|
||
| }) | ||
|
|
||
| return res.status(200).json({ message: 'Password has been reset successfully. You can now sign in.' }) | ||
| } catch (err) { | ||
| console.error('[ResetPassword] Error:', err) | ||
|
|
||
| return res.status(500).json({ error: 'Something went wrong. Please try again later.' }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
usedandcreated_atare nullable as defined here; that can lead to ambiguous semantics (NULL vs FALSE) and makes queries more error-prone. Considerused BOOLEAN NOT NULL DEFAULT FALSEandcreated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP(and optionally a UNIQUE constraint/index ontoken_hashsince the API assumes one row per hash).