From cef30e5048326c579ae534679ce798a085cd647e Mon Sep 17 00:00:00 2001 From: AkifFarhan Date: Fri, 21 Aug 2026 08:05:05 +0600 Subject: [PATCH] feat: implement email authentication with OTP verification - Add email verification flow with OTP tokens - Implement forgot/reset password functionality - Add mail service for sending verification emails - Create OTP token model and migrations - Add client views for verify email, forgot password, reset password - Add view models for email auth flows - Update auth schema and service for email-based authentication --- client/src/App.tsx | 6 + client/src/components/OtpInput.tsx | 40 ++++ client/src/viewmodels/AuthContext.tsx | 23 +- .../viewmodels/useForgotPasswordViewModel.ts | 49 ++++ client/src/viewmodels/useLoginViewModel.ts | 19 +- client/src/viewmodels/useRegisterViewModel.ts | 2 +- client/src/viewmodels/useResendCooldown.ts | 22 ++ .../viewmodels/useResetPasswordViewModel.ts | 128 ++++++++++ .../src/viewmodels/useVerifyEmailViewModel.ts | 81 +++++++ client/src/views/ForgotPasswordView.tsx | 62 +++++ client/src/views/LoginView.tsx | 5 +- client/src/views/ResetPasswordView.tsx | 135 +++++++++++ client/src/views/VerifyEmailView.tsx | 86 +++++++ server/package-lock.json | 42 ---- .../20260817120000_otp_tokens/migration.sql | 15 ++ server/prisma/schema.prisma | 13 +- server/src/lib/crypto.js | 26 ++ server/src/lib/mailer.js | 22 +- server/src/modules/auth/schema.js | 21 +- server/src/modules/auth/service.js | 126 +++++++--- server/test/auth.test.js | 225 +++++++++++++++++- 21 files changed, 1031 insertions(+), 117 deletions(-) create mode 100644 client/src/components/OtpInput.tsx create mode 100644 client/src/viewmodels/useForgotPasswordViewModel.ts create mode 100644 client/src/viewmodels/useResendCooldown.ts create mode 100644 client/src/viewmodels/useResetPasswordViewModel.ts create mode 100644 client/src/viewmodels/useVerifyEmailViewModel.ts create mode 100644 client/src/views/ForgotPasswordView.tsx create mode 100644 client/src/views/ResetPasswordView.tsx create mode 100644 client/src/views/VerifyEmailView.tsx create mode 100644 server/prisma/migrations/20260817120000_otp_tokens/migration.sql diff --git a/client/src/App.tsx b/client/src/App.tsx index 6c90aca..bb820c4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,6 +10,9 @@ import SplashView from './views/SplashView'; import WelcomeView from './views/WelcomeView'; import LoginView from './views/LoginView'; import RegisterView from './views/RegisterView'; +import ForgotPasswordView from './views/ForgotPasswordView'; +import VerifyEmailView from './views/VerifyEmailView'; +import ResetPasswordView from './views/ResetPasswordView'; import LandingPage from './views/LandingPage'; import HomeView from './views/HomeView'; import TodoListView from './views/TodoListView'; @@ -50,6 +53,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> {/* Dashboard (AppLayout shell — top nav, protected app routes). RequireAuth bounces to /login when there is no restored session, diff --git a/client/src/components/OtpInput.tsx b/client/src/components/OtpInput.tsx new file mode 100644 index 0000000..0a212dd --- /dev/null +++ b/client/src/components/OtpInput.tsx @@ -0,0 +1,40 @@ +// Six-digit one-time-code input, styled identically to the auth form fields +// (Field.tsx) so the verify-email and reset-password screens are +// indistinguishable from Login/Register. Digits only, six maximum; used by +// both OTP screens, never duplicated. +export default function OtpInput({ + value, + onChange, + error, + disabled, + autoFocus, +}: { + value: string; + onChange: (value: string) => void; + error?: string; + disabled?: boolean; + autoFocus?: boolean; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/client/src/viewmodels/AuthContext.tsx b/client/src/viewmodels/AuthContext.tsx index 0e782d1..be8350b 100644 --- a/client/src/viewmodels/AuthContext.tsx +++ b/client/src/viewmodels/AuthContext.tsx @@ -49,6 +49,9 @@ interface AuthContextValue { }) => Promise; signOut: () => Promise; forgotPassword: (email: string) => Promise; + verifyEmail: (email: string, otp: string) => Promise; + resendVerification: (email: string) => Promise; + resetPassword: (email: string, otp: string, newPassword: string) => Promise; refreshProfile: () => Promise; } @@ -124,6 +127,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { await apiClient.post('/auth/forgot-password', { email }); }, []); + const verifyEmail = useCallback(async (email: string, otp: string) => { + await apiClient.post('/auth/verify-email', { email, otp }); + }, []); + + const resendVerification = useCallback(async (email: string) => { + await apiClient.post('/auth/resend-verification', { email }); + }, []); + + const resetPassword = useCallback( + async (email: string, otp: string, newPassword: string) => { + await apiClient.post('/auth/reset-password', { email, otp, newPassword }); + }, + [], + ); + const refreshProfile = useCallback(async () => { const me = await apiClient.get('/me'); setUser(me); @@ -139,9 +157,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { register, signOut, forgotPassword, + verifyEmail, + resendVerification, + resetPassword, refreshProfile, }), - [user, isRestoring, login, register, signOut, forgotPassword, refreshProfile], + [user, isRestoring, login, register, signOut, forgotPassword, verifyEmail, resendVerification, resetPassword, refreshProfile], ); return {children}; diff --git a/client/src/viewmodels/useForgotPasswordViewModel.ts b/client/src/viewmodels/useForgotPasswordViewModel.ts new file mode 100644 index 0000000..4bbd5f2 --- /dev/null +++ b/client/src/viewmodels/useForgotPasswordViewModel.ts @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useAuth, authErrorMessage } from './AuthContext'; + +/** + * Forgot-password screen behaviour: ask for the address, ask the server to + * issue a code, then hand off to the reset screen with the email carried in + * router state. The server replies identically whether or not the address has + * an account, so no path here distinguishes the two. + */ +export function useForgotPasswordViewModel() { + const navigate = useNavigate(); + const { forgotPassword } = useAuth(); + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(null); + + async function submit(): Promise { + if (!email.trim()) { + setMessage('Enter your email address.'); + return false; + } + setIsLoading(true); + try { + await forgotPassword(email.trim()); + navigate('/reset-password', { state: { email: email.trim() } }); + return true; + } catch (error) { + setMessage(authErrorMessage(error)); + return false; + } finally { + setIsLoading(false); + } + } + + function clearMessage() { + if (message) setMessage(null); + } + + return { + email, + setEmail, + isLoading, + message, + submit, + clearMessage, + }; +} \ No newline at end of file diff --git a/client/src/viewmodels/useLoginViewModel.ts b/client/src/viewmodels/useLoginViewModel.ts index 208a7d4..4d8ee97 100644 --- a/client/src/viewmodels/useLoginViewModel.ts +++ b/client/src/viewmodels/useLoginViewModel.ts @@ -7,7 +7,7 @@ import { useAuth, authErrorMessage } from './AuthContext'; // the API, replacing the previous mock (any non-empty email/password). export function useLoginViewModel() { const navigate = useNavigate(); - const { login, forgotPassword } = useAuth(); + const { login } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [rememberMe, setRememberMe] = useState(false); @@ -30,22 +30,6 @@ export function useLoginViewModel() { } } - async function requestPasswordReset() { - if (!email.trim()) { - setMessage('Enter your email above to reset your password.'); - return; - } - try { - await forgotPassword(email.trim()); - // Phrased to match the server, which deliberately answers the same way - // whether or not the address has an account — see forgotPassword in - // the API. This is not a display bug; it is the point. - setMessage('If that email has an account, a reset link is on its way.'); - } catch (error) { - setMessage(authErrorMessage(error)); - } - } - function clearMessage() { if (message) setMessage(null); } @@ -60,7 +44,6 @@ export function useLoginViewModel() { isLoading, message, login: submit, - forgotPassword: requestPasswordReset, clearMessage, }; } diff --git a/client/src/viewmodels/useRegisterViewModel.ts b/client/src/viewmodels/useRegisterViewModel.ts index 3511ebf..3775060 100644 --- a/client/src/viewmodels/useRegisterViewModel.ts +++ b/client/src/viewmodels/useRegisterViewModel.ts @@ -30,7 +30,7 @@ export function useRegisterViewModel() { setIsLoading(true); try { await register({ name: name.trim(), email: email.trim(), password }); - navigate('/home', { replace: true }); + navigate('/verify-email', { replace: true, state: { email: email.trim() } }); } catch (error) { const fieldError = error instanceof ApiError ? error.errorFor('password') : undefined; setMessage(fieldError ?? authErrorMessage(error)); diff --git a/client/src/viewmodels/useResendCooldown.ts b/client/src/viewmodels/useResendCooldown.ts new file mode 100644 index 0000000..64843c9 --- /dev/null +++ b/client/src/viewmodels/useResendCooldown.ts @@ -0,0 +1,22 @@ +import { useCallback, useEffect, useState } from 'react'; + +const DEFAULT_COOLDOWN_SECONDS = 45; + +/** + * Disable a "resend code" action for a short window after each send. + * Shared by the verify-email and reset-password screens so both resend + * buttons behave identically and the code is not re-issued on demand. + */ +export function useResendCooldown(seconds = DEFAULT_COOLDOWN_SECONDS) { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (secondsLeft <= 0) return; + const timer = setInterval(() => setSecondsLeft((prev) => Math.max(0, prev - 1)), 1000); + return () => clearInterval(timer); + }, [secondsLeft]); + + const start = useCallback(() => setSecondsLeft(seconds), [seconds]); + + return { secondsLeft, canSend: secondsLeft === 0, start }; +} \ No newline at end of file diff --git a/client/src/viewmodels/useResetPasswordViewModel.ts b/client/src/viewmodels/useResetPasswordViewModel.ts new file mode 100644 index 0000000..fb8dbf0 --- /dev/null +++ b/client/src/viewmodels/useResetPasswordViewModel.ts @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { ApiError } from '../api/errors'; +import { useAuth, authErrorMessage } from './AuthContext'; +import { useResendCooldown } from './useResendCooldown'; + +/** + * Two-step password reset on one screen. + * + * Step 1 captures the emailed code; step 2 the new password. Only the final + * submit talks to the server — there is no OTP-only endpoint — so a code + * failure bounces the user back to step 1 rather than leaving them stranded + * on the password form. Password policy is enforced server-side and surfaced + * through the same per-field error mechanism Register uses. + */ +export function useResetPasswordViewModel() { + const navigate = useNavigate(); + const location = useLocation(); + const { forgotPassword, resetPassword } = useAuth(); + const { secondsLeft, canSend, start } = useResendCooldown(); + + const email = (location.state as { email?: string } | null)?.email ?? ''; + const [step, setStep] = useState<'otp' | 'password'>('otp'); + const [otp, setOtp] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isResending, setIsResending] = useState(false); + const [message, setMessage] = useState(null); + + // A code was just issued on the forgot-password screen, so the resend is + // gated by the cooldown from the moment this screen mounts. + useEffect(() => { + start(); + }, [start]); + + function next(): boolean { + if (otp.length !== 6) { + setMessage('Enter the 6-digit code sent to your email.'); + return false; + } + setMessage(null); + setStep('password'); + return true; + } + + function backToOtp() { + setStep('otp'); + setMessage(null); + } + + async function submit(): Promise { + if (!newPassword || !confirmPassword) { + setMessage('Please fill in all fields.'); + return false; + } + if (newPassword !== confirmPassword) { + setMessage('Passwords do not match.'); + return false; + } + setIsLoading(true); + try { + await resetPassword(email, otp, newPassword); + navigate('/login', { replace: true }); + return true; + } catch (error) { + const isOtpFailure = + error instanceof ApiError && (error.isUnauthorized || error.isForbidden); + if (isOtpFailure) { + // Wrong or locked code — let them correct it or ask for a fresh one. + setStep('otp'); + setOtp(''); + setMessage(authErrorMessage(error)); + } else { + // Password-policy or network failure — stay on the password step. + const fieldError = error instanceof ApiError ? error.errorFor('newPassword') : undefined; + setMessage(fieldError ?? authErrorMessage(error)); + } + return false; + } finally { + setIsLoading(false); + } + } + + async function resend(): Promise { + setIsResending(true); + try { + // Re-issuing a reset code is exactly what /forgot-password does — it + // supersedes the old code and emails a fresh one. + await forgotPassword(email); + setStep('otp'); + setOtp(''); + start(); + return true; + } catch (error) { + setMessage(authErrorMessage(error)); + return false; + } finally { + setIsResending(false); + } + } + + function clearMessage() { + if (message) setMessage(null); + } + + return { + email, + step, + otp, + setOtp, + newPassword, + setNewPassword, + confirmPassword, + setConfirmPassword, + isLoading, + isResending, + message, + secondsLeft, + canSend, + next, + backToOtp, + submit, + resend, + clearMessage, + }; +} \ No newline at end of file diff --git a/client/src/viewmodels/useVerifyEmailViewModel.ts b/client/src/viewmodels/useVerifyEmailViewModel.ts new file mode 100644 index 0000000..49b2181 --- /dev/null +++ b/client/src/viewmodels/useVerifyEmailViewModel.ts @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useAuth, authErrorMessage } from './AuthContext'; +import { useResendCooldown } from './useResendCooldown'; + +/** + * Verify-email screen behaviour: takes the address registered moments ago + * (carried over via router state), submits the emailed code, and — because + * registration already returned a session — lands the user in the app on + * success. + */ +export function useVerifyEmailViewModel() { + const navigate = useNavigate(); + const location = useLocation(); + const { verifyEmail, resendVerification } = useAuth(); + const { secondsLeft, canSend, start } = useResendCooldown(); + + const email = (location.state as { email?: string } | null)?.email ?? ''; + const [otp, setOtp] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isResending, setIsResending] = useState(false); + const [message, setMessage] = useState(null); + + // A code was just issued at registration, so the resend is gated by the + // cooldown from the moment the screen mounts. + useEffect(() => { + start(); + }, [start]); + + async function verify(): Promise { + if (otp.length !== 6) { + setMessage('Enter the 6-digit code sent to your email.'); + return false; + } + setIsLoading(true); + try { + await verifyEmail(email, otp); + navigate('/home', { replace: true }); + return true; + } catch (error) { + setMessage(authErrorMessage(error)); + return false; + } finally { + setIsLoading(false); + } + } + + async function resend(): Promise { + setIsResending(true); + try { + await resendVerification(email); + setOtp(''); + start(); + return true; + } catch (error) { + setMessage(authErrorMessage(error)); + return false; + } finally { + setIsResending(false); + } + } + + function clearMessage() { + if (message) setMessage(null); + } + + return { + email, + otp, + setOtp, + isLoading, + isResending, + message, + secondsLeft, + canSend, + verify, + resend, + clearMessage, + }; +} \ No newline at end of file diff --git a/client/src/views/ForgotPasswordView.tsx b/client/src/views/ForgotPasswordView.tsx new file mode 100644 index 0000000..f4a2c98 --- /dev/null +++ b/client/src/views/ForgotPasswordView.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Loader2, Mail, ArrowRight } from 'lucide-react'; +import Field from '../components/Field'; +import AuthLayout from '../components/AuthLayout'; +import { useToast } from '../components/Toast'; +import { useForgotPasswordViewModel } from '../viewmodels/useForgotPasswordViewModel'; + +// The first leg of the OTP password reset: collect the address, issue the +// code, then move on to /reset-password with the email in router state. +// Styled identically to Login/Register (same AuthLayout, Field, button). +export default function ForgotPasswordView() { + const toast = useToast(); + const vm = useForgotPasswordViewModel(); + + useEffect(() => { + if (vm.message) { + toast(vm.message, 'error'); + vm.clearMessage(); + } + }, [vm, vm.message, toast]); + + async function handleSubmit() { + const ok = await vm.submit(); + if (ok) toast('If that email has an account, a reset code is on its way.', 'success'); + } + + return ( + +
+ } + value={vm.email} + onChange={(e) => vm.setEmail(e.target.value)} + placeholder="you@aust.edu" + type="email" + /> +
+ + + +
+ Remembered it?{' '} + + Back to sign in + +
+
+ ); +} \ No newline at end of file diff --git a/client/src/views/LoginView.tsx b/client/src/views/LoginView.tsx index 53b4de0..417e893 100644 --- a/client/src/views/LoginView.tsx +++ b/client/src/views/LoginView.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { Loader2, Lock, Mail, ArrowRight } from 'lucide-react'; import Field from '../components/Field'; import AuthLayout from '../components/AuthLayout'; @@ -12,6 +12,7 @@ import { useLoginViewModel } from '../viewmodels/useLoginViewModel'; export default function LoginView() { const toast = useToast(); const vm = useLoginViewModel(); + const navigate = useNavigate(); useEffect(() => { if (vm.message) { @@ -56,7 +57,7 @@ export default function LoginView() { + +
+ Didn't get the code?{' '} + +
+ + ) : ( + <> +
+ } + value={vm.newPassword} + onChange={(e) => vm.setNewPassword(e.target.value)} + placeholder="Enter your new password" + type="password" + /> + } + value={vm.confirmPassword} + onChange={(e) => vm.setConfirmPassword(e.target.value)} + placeholder="Re-enter your new password" + type="password" + /> +
+ + + +
+ +
+ + )} + +
+ + Back to sign in + +
+ + ); +} \ No newline at end of file diff --git a/client/src/views/VerifyEmailView.tsx b/client/src/views/VerifyEmailView.tsx new file mode 100644 index 0000000..08424ea --- /dev/null +++ b/client/src/views/VerifyEmailView.tsx @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { Loader2, ArrowRight } from 'lucide-react'; +import AuthLayout from '../components/AuthLayout'; +import OtpInput from '../components/OtpInput'; +import { useToast } from '../components/Toast'; +import { useVerifyEmailViewModel } from '../viewmodels/useVerifyEmailViewModel'; + +// The post-registration stop: enter the 6-digit code emailed at signup. +// Reuses AuthLayout and the auth-form button/error/toast conventions exactly, +// so the screen is indistinguishable from Login/Register in spacing, type, +// and states. +export default function VerifyEmailView() { + const toast = useToast(); + const vm = useVerifyEmailViewModel(); + + useEffect(() => { + if (vm.message) { + toast(vm.message, 'error'); + vm.clearMessage(); + } + }, [vm, vm.message, toast]); + + async function handleVerify() { + const ok = await vm.verify(); + if (ok) toast('Email verified. Welcome to AUST Central!', 'success'); + } + + async function handleResend() { + const sent = await vm.resend(); + if (sent) toast('A new code is on its way.', 'success'); + } + + if (!vm.email) { + // No email was carried over (deep link / refresh on the page). + return ; + } + + return ( + +
+ +
+ + + +
+ Didn't get the code?{' '} + +
+ +
+ + Sign in with a different account + +
+
+ ); +} \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 9396c67..83d2a4e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1107,9 +1107,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1311,9 +1308,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1328,9 +1322,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1345,9 +1336,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1362,9 +1350,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1379,9 +1364,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1396,9 +1378,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1413,9 +1392,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1430,9 +1406,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1447,9 +1420,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1464,9 +1434,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1481,9 +1448,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1498,9 +1462,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1515,9 +1476,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/server/prisma/migrations/20260817120000_otp_tokens/migration.sql b/server/prisma/migrations/20260817120000_otp_tokens/migration.sql new file mode 100644 index 0000000..93b42e2 --- /dev/null +++ b/server/prisma/migrations/20260817120000_otp_tokens/migration.sql @@ -0,0 +1,15 @@ +-- Email verification and password reset now use 6-digit OTPs. +-- +-- A numeric code has only ~10^6 possible values, so the same code is legitimately +-- issued to many users; the old unique token-hash constraint would reject that +-- collision. Each row now also counts failed attempts so a code can be locked +-- out against brute force. +-- +-- The hashes stay irreversible: SHA-256 keyed with the pepper, computed in +-- src/lib/crypto.js. Plaintext codes are never stored. + +DROP INDEX IF EXISTS "email_verification_tokens_tokenHash_key"; +DROP INDEX IF EXISTS "password_reset_tokens_tokenHash_key"; + +ALTER TABLE "email_verification_tokens" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "password_reset_tokens" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1f305db..0d48c2d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -122,10 +122,14 @@ model PasswordResetToken { userId String @db.Uuid user User @relation(fields: [userId], references: [id], onDelete: Cascade) - tokenHash String @unique + // SHA-256 (keyed with PASSWORD_PEPPER) of a 6-digit code, so a database dump + // cannot be replayed. Not unique: two users legitimately receive the same + // code, and attempts are counted per row to lock out brute-forcing. + tokenHash String expiresAt DateTime usedAt DateTime? - createdAt DateTime @default(now()) + attempts Int @default(0) + createdAt DateTime @default(now()) @@index([userId]) @@map("password_reset_tokens") @@ -136,10 +140,11 @@ model EmailVerificationToken { userId String @db.Uuid user User @relation(fields: [userId], references: [id], onDelete: Cascade) - tokenHash String @unique + tokenHash String expiresAt DateTime usedAt DateTime? - createdAt DateTime @default(now()) + attempts Int @default(0) + createdAt DateTime @default(now()) @@index([userId]) @@map("email_verification_tokens") diff --git a/server/src/lib/crypto.js b/server/src/lib/crypto.js index b832b14..93af976 100644 --- a/server/src/lib/crypto.js +++ b/server/src/lib/crypto.js @@ -1,6 +1,8 @@ import { createHash, + createHmac, randomBytes, + randomInt, randomUUID, timingSafeEqual, } from 'node:crypto'; @@ -101,6 +103,30 @@ export function generateOpaqueToken() { return { token, hash: hashToken(token) }; } +/** + * A 6-digit numeric one-time code plus its storage hash. + * + * Unlike the opaque tokens above, a 6-digit code has only ~10^6 possible + * values, so a bare SHA-256 would let someone with a table dump brute-force + * the whole keyspace offline. It is therefore hashed with HMAC-SHA256 keyed by + * `PASSWORD_PEPPER` — the same secret that already protects password hashes — + * so verification of a dumped database still requires the pepper. + * + * @returns {{ code: string, hash: string }} + */ +export function generateOtp() { + const code = String(randomInt(0, 1_000_000)).padStart(6, '0'); + return { code, hash: hashOtp(code) }; +} + +/** + * @param {string} code + * @returns {string} Hex HMAC-SHA256 keyed with the pepper. + */ +export function hashOtp(code) { + return createHmac('sha256', env.PASSWORD_PEPPER).update(code).digest('hex'); +} + /** * @param {string} token * @returns {string} Hex SHA-256, safe to index and compare in SQL. diff --git a/server/src/lib/mailer.js b/server/src/lib/mailer.js index 3b62462..6904562 100644 --- a/server/src/lib/mailer.js +++ b/server/src/lib/mailer.js @@ -71,33 +71,31 @@ export async function sendMail({ to, subject, text, logger }) { } } -/** @param {string} token */ -export function passwordResetEmail(token) { - const link = `${env.PUBLIC_URL}/reset-password?token=${encodeURIComponent(token)}`; +/** @param {string} otp */ +export function passwordResetEmail(otp) { return { - subject: 'Reset your AUST Central password', + subject: 'Your AUST Central password reset code', text: [ 'We received a request to reset your AUST Central password.', '', - `Reset link: ${link}`, + `Your reset code is: ${otp}`, '', - 'This link can be used once and expires in 30 minutes.', + 'Enter this code on the password reset screen. It can be used once and expires in 15 minutes.', "If you didn't request this, you can ignore this email — your password will not change.", ].join('\n'), }; } -/** @param {string} token */ -export function verificationEmail(token) { - const link = `${env.PUBLIC_URL}/verify-email?token=${encodeURIComponent(token)}`; +/** @param {string} otp */ +export function verificationEmail(otp) { return { - subject: 'Verify your AUST Central email', + subject: 'Your AUST Central verification code', text: [ 'Welcome to AUST Central.', '', - `Confirm your address: ${link}`, + `Your verification code is: ${otp}`, '', - 'This link expires in 24 hours.', + 'Enter this code on the verification screen. It can be used once and expires in 15 minutes.', ].join('\n'), }; } diff --git a/server/src/modules/auth/schema.js b/server/src/modules/auth/schema.js index ea43944..a8ea742 100644 --- a/server/src/modules/auth/schema.js +++ b/server/src/modules/auth/schema.js @@ -53,6 +53,17 @@ export const passwordSchema = z export const nameSchema = z.string().trim().min(2, 'Name is required').max(120); +/** + * A 6-digit numeric code from the verification / reset emails. + * + * Exactly six digits and nothing else: the code is compared as a hash server + * side, and the shape here is what keeps that comparison bounded. + */ +export const otpSchema = z + .string() + .trim() + .regex(/^\d{6}$/, 'Enter the 6-digit code'); + // --- Requests --------------------------------------------------------------- export const registerBodySchema = z @@ -91,13 +102,17 @@ export const forgotPasswordBodySchema = z.object({ email: emailSchema }).strict( export const resetPasswordBodySchema = z .object({ - token: z.string().min(1).max(200), - password: passwordSchema, + email: emailSchema, + otp: otpSchema, + newPassword: passwordSchema, }) .strict(); export const verifyEmailBodySchema = z - .object({ token: z.string().min(1).max(200) }) + .object({ + email: emailSchema, + otp: otpSchema, + }) .strict(); export const resendVerificationBodySchema = z.object({ email: emailSchema }).strict(); diff --git a/server/src/modules/auth/service.js b/server/src/modules/auth/service.js index c563c70..0402d69 100644 --- a/server/src/modules/auth/service.js +++ b/server/src/modules/auth/service.js @@ -1,11 +1,14 @@ import { env } from '../../config/env.js'; import { fakeVerifyDelay, + generateOtp, generateOpaqueToken, + hashOtp, hashPassword, hashToken, needsRehash, newTokenFamilyId, + safeEqual, verifyPassword, } from '../../lib/crypto.js'; import { @@ -31,8 +34,8 @@ import { toPublicUser } from './schema.js'; const MAX_FAILED_LOGINS = 5; const LOCKOUT_MINUTES = 15; -const RESET_TOKEN_TTL_MINUTES = 30; -const VERIFY_TOKEN_TTL_HOURS = 24; +const OTP_TTL_MINUTES = 15; +const MAX_OTP_ATTEMPTS = 5; /** Access-token lifetime in seconds, for the `expiresIn` field. */ function accessTokenSeconds() { @@ -101,6 +104,49 @@ function buildSession(app, user, refreshToken) { }; } +/** + * Validate a presented 6-digit code against the freshest active code for a + * user, on a given token model (`emailVerificationToken` or + * `passwordResetToken`). + * + * Failure paths: + * - no active code, or an expired one → 401, same message regardless so a + * caller cannot tell "no account" from "code spent" + * - a wrong code counts toward the per-code attempt budget; once exhausted + * the code is locked (403) until a fresh one is issued, so a 6-digit guess + * space cannot be walked through the API + * + * On success the caller is responsible for marking the row used. + */ +async function consumeOtp(app, { userId, code, model }) { + const stored = await app.prisma[model].findFirst({ + where: { userId, usedAt: null }, + orderBy: { createdAt: 'desc' }, + }); + + if (!stored || stored.expiresAt < new Date()) { + throw new UnauthorizedError('This code is invalid or has expired. Request a new one.'); + } + + if (stored.attempts >= MAX_OTP_ATTEMPTS) { + throw new ForbiddenError('Too many incorrect codes. Request a new one.'); + } + + if (!safeEqual(hashOtp(code), stored.tokenHash)) { + const attempts = stored.attempts + 1; + const locked = attempts >= MAX_OTP_ATTEMPTS; + await app.prisma[model].update({ + where: { id: stored.id }, + data: { attempts }, + }); + throw locked + ? new ForbiddenError('Too many incorrect codes. Request a new one.') + : new UnauthorizedError('Incorrect code. Please try again.'); + } + + return stored; +} + // --------------------------------------------------------------------------- export async function register(app, request, input) { @@ -132,16 +178,16 @@ export async function register(app, request, input) { }, }); - const { token, hash } = generateOpaqueToken(); + const { code, hash } = generateOtp(); await app.prisma.emailVerificationToken.create({ data: { userId: user.id, tokenHash: hash, - expiresAt: new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000), + expiresAt: new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000), }, }); - const mail = verificationEmail(token); + const mail = verificationEmail(code); await sendMail({ to: user.email, ...mail, logger: request.log }); await audit(app, { @@ -346,22 +392,22 @@ export async function forgotPassword(app, request, { email }) { const user = await app.prisma.user.findUnique({ where: { email } }); if (user && !user.deletedAt) { - // Invalidate outstanding resets so only the newest link works. + // Invalidate outstanding resets so only the newest code works. await app.prisma.passwordResetToken.updateMany({ where: { userId: user.id, usedAt: null }, data: { usedAt: new Date() }, }); - const { token, hash } = generateOpaqueToken(); + const { code, hash } = generateOtp(); await app.prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash: hash, - expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000), + expiresAt: new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000), }, }); - const mail = passwordResetEmail(token); + const mail = passwordResetEmail(code); await sendMail({ to: user.email, ...mail, logger: request.log }); await audit(app, { @@ -379,22 +425,37 @@ export async function forgotPassword(app, request, { email }) { } return { - message: 'If that email has an account, a reset link is on its way.', + message: 'If that email has an account, a reset code is on its way.', }; } -export async function resetPassword(app, request, { token, password }) { - const stored = await app.prisma.passwordResetToken.findUnique({ - where: { tokenHash: hashToken(token) }, - include: { user: true }, - }); +export async function resetPassword(app, request, { email, otp, newPassword }) { + const user = await app.prisma.user.findUnique({ where: { email } }); - if (!stored || stored.usedAt || stored.expiresAt < new Date() || stored.user.deletedAt) { - await audit(app, { action: 'auth.password_reset_invalid_token', request }); - throw new UnauthorizedError('This reset link is invalid or has expired'); + if (!user || user.deletedAt) { + await audit(app, { + action: 'auth.password_reset_invalid_code', + request, + }); + throw new UnauthorizedError('This code is invalid or has expired. Request a new one.'); + } + + let stored; + try { + stored = await consumeOtp(app, { + userId: user.id, + code: otp, + model: 'passwordResetToken', + }); + } catch (error) { + await audit(app, { + action: 'auth.password_reset_invalid_code', + request, + }); + throw error; } - const passwordHash = await hashPassword(password); + const passwordHash = await hashPassword(newPassword); await app.prisma.$transaction([ app.prisma.user.update({ @@ -424,16 +485,21 @@ export async function resetPassword(app, request, { token, password }) { return { message: 'Password updated. Please sign in with your new password.' }; } -export async function verifyEmail(app, request, { token }) { - const stored = await app.prisma.emailVerificationToken.findUnique({ - where: { tokenHash: hashToken(token) }, - include: { user: true }, - }); +export async function verifyEmail(app, request, { email, otp }) { + const user = await app.prisma.user.findUnique({ where: { email } }); - if (!stored || stored.usedAt || stored.expiresAt < new Date()) { - throw new UnauthorizedError('This verification link is invalid or has expired'); + // Same reply for an unknown address, a deleted account, an already-verified + // one, or a spent code — no path here may reveal whether the address exists. + if (!user || user.deletedAt || user.emailVerifiedAt) { + throw new UnauthorizedError('This code is invalid or has expired. Request a new one.'); } + const stored = await consumeOtp(app, { + userId: user.id, + code: otp, + model: 'emailVerificationToken', + }); + await app.prisma.$transaction([ app.prisma.user.update({ where: { id: stored.userId }, @@ -465,21 +531,21 @@ export async function resendVerification(app, request, { email }) { data: { usedAt: new Date() }, }); - const { token, hash } = generateOpaqueToken(); + const { code, hash } = generateOtp(); await app.prisma.emailVerificationToken.create({ data: { userId: user.id, tokenHash: hash, - expiresAt: new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000), + expiresAt: new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000), }, }); - const mail = verificationEmail(token); + const mail = verificationEmail(code); await sendMail({ to: user.email, ...mail, logger: request.log }); } // Same reply regardless — including for an address that is already verified. - return { message: 'If that address needs verification, a new link is on its way.' }; + return { message: 'If that address needs verification, a new code is on its way.' }; } export async function changePassword(app, request, userId, { currentPassword, newPassword }) { diff --git a/server/test/auth.test.js b/server/test/auth.test.js index 1e2a130..46d6d41 100644 --- a/server/test/auth.test.js +++ b/server/test/auth.test.js @@ -1,6 +1,7 @@ -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTestApp, useCleanDatabase } from './helpers/app.js'; +import * as crypto from '../src/lib/crypto.js'; /** * Auth suite. @@ -30,6 +31,23 @@ async function registerUser(overrides = {}) { return { response, body: response.json() }; } +/** + * Pin OTP generation so tests know the code a user "receives". + * + * The mailer only logs in the test environment, so the code is not readable + * from the transport; stubbing the generator is the next best thing — it + * exercises the real storage, hashing, and comparison paths end to end. + */ +function fixedOtp() { + const code = '123456'; + vi.spyOn(crypto, 'generateOtp').mockReturnValue({ code, hash: crypto.hashOtp(code) }); + return code; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + describe('POST /auth/register', () => { it('creates an account and returns a session', async () => { const { response, body } = await registerUser(); @@ -212,13 +230,212 @@ describe('POST /auth/forgot-password', () => { }); describe('POST /auth/reset-password', () => { - it('rejects an invalid token', async () => { + it('rejects a code that was never issued', async () => { + await registerUser(); + const response = await post('/reset-password', { + email: VALID.email, + otp: '123456', + newPassword: 'BrandNewPass1', + }); + expect(response.statusCode).toBe(401); + }); + + it('resets the password with the emailed code and revokes every session', async () => { + const { body } = await registerUser(); + const code = fixedOtp(); + await post('/forgot-password', { email: VALID.email }); + + const response = await post('/reset-password', { + email: VALID.email, + otp: code, + newPassword: 'BrandNewPass1', + }); + expect(response.statusCode).toBe(200); + + // The old password no longer works. + const oldLogin = await post('/login', { email: VALID.email, password: VALID.password }); + expect(oldLogin.statusCode).toBe(401); + + // The new password does. + const newLogin = await post('/login', { email: VALID.email, password: 'BrandNewPass1' }); + expect(newLogin.statusCode).toBe(200); + + // The session issued at register dies with the reset. + const stale = await post('/refresh', { refreshToken: body.refreshToken }); + expect(stale.statusCode).toBe(401); + }); + + it('accepts a reset code only once', async () => { + await registerUser(); + const code = fixedOtp(); + await post('/forgot-password', { email: VALID.email }); + + const first = await post('/reset-password', { + email: VALID.email, + otp: code, + newPassword: 'BrandNewPass1', + }); + expect(first.statusCode).toBe(200); + + const replay = await post('/reset-password', { + email: VALID.email, + otp: code, + newPassword: 'AnotherPass1', + }); + expect(replay.statusCode).toBe(401); + }); + + it('rejects a wrong reset code', async () => { + await registerUser(); + await post('/forgot-password', { email: VALID.email }); + const response = await post('/reset-password', { - token: 'bogus', - password: 'BrandNewPass1', + email: VALID.email, + otp: '000000', + newPassword: 'BrandNewPass1', }); expect(response.statusCode).toBe(401); }); + + it('rejects an expired reset code', async () => { + await registerUser(); + const code = fixedOtp(); + await post('/forgot-password', { email: VALID.email }); + await app.prisma.passwordResetToken.updateMany({ + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const response = await post('/reset-password', { + email: VALID.email, + otp: code, + newPassword: 'BrandNewPass1', + }); + expect(response.statusCode).toBe(401); + }); + + it('locks the reset code after too many wrong attempts', async () => { + await registerUser(); + const code = fixedOtp(); + await post('/forgot-password', { email: VALID.email }); + + for (let attempt = 0; attempt < 5; attempt += 1) { + await post('/reset-password', { + email: VALID.email, + otp: '000000', + newPassword: 'BrandNewPass1', + }); + } + + // Even the correct code is now refused — the budget is spent. + const correct = await post('/reset-password', { + email: VALID.email, + otp: code, + newPassword: 'BrandNewPass1', + }); + expect(correct.statusCode).toBe(403); + }); + + it('answers the same for an unknown email as for a spent code', async () => { + const unknown = await post('/reset-password', { + email: 'nobody@example.com', + otp: '123456', + newPassword: 'BrandNewPass1', + }); + expect(unknown.statusCode).toBe(401); + + // Same message as the "never issued" case above — no account oracle. + expect(unknown.json().error.message).toBe( + 'This code is invalid or has expired. Request a new one.', + ); + }); + + it('enforces the password policy on the new password', async () => { + await registerUser(); + const code = fixedOtp(); + await post('/forgot-password', { email: VALID.email }); + + const response = await post('/reset-password', { + email: VALID.email, + otp: code, + newPassword: 'weak', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error.details.fields.newPassword).toBeDefined(); + }); +}); + +describe('POST /auth/verify-email', () => { + it('verifies the address with the emailed code, once', async () => { + const code = fixedOtp(); + await registerUser(); + + const response = await post('/verify-email', { email: VALID.email, otp: code }); + expect(response.statusCode).toBe(200); + + const user = await app.prisma.user.findUnique({ where: { email: VALID.email } }); + expect(user.emailVerifiedAt).not.toBeNull(); + + // The same code cannot verify again. + const replay = await post('/verify-email', { email: VALID.email, otp: code }); + expect(replay.statusCode).toBe(401); + }); + + it('rejects a wrong code', async () => { + fixedOtp(); + await registerUser(); + const response = await post('/verify-email', { email: VALID.email, otp: '000000' }); + expect(response.statusCode).toBe(401); + expect(response.json().error.message).not.toBe('Email verified.'); + }); + + it('rejects an expired code', async () => { + const code = fixedOtp(); + await registerUser(); + await app.prisma.emailVerificationToken.updateMany({ + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const response = await post('/verify-email', { email: VALID.email, otp: code }); + expect(response.statusCode).toBe(401); + }); + + it('locks the code after too many wrong attempts', async () => { + const code = fixedOtp(); + await registerUser(); + + for (let attempt = 0; attempt < 5; attempt += 1) { + await post('/verify-email', { email: VALID.email, otp: '000000' }); + } + + const correct = await post('/verify-email', { email: VALID.email, otp: code }); + expect(correct.statusCode).toBe(403); + }); + + it('answers identically for an unknown address and a spent code', async () => { + const unknown = await post('/verify-email', { email: 'nobody@example.com', otp: '123456' }); + expect(unknown.statusCode).toBe(401); + + const code = fixedOtp(); + await registerUser(); + await post('/verify-email', { email: VALID.email, otp: code }); + + const spent = await post('/verify-email', { email: VALID.email, otp: code }); + expect(spent.statusCode).toBe(401); + expect(spent.json().error.message).toBe(unknown.json().error.message); + }); + + it('does not let a resend before verification exceed one live code', async () => { + const code = fixedOtp(); + await registerUser(); + await post('/resend-verification', { email: VALID.email }); + + const live = await app.prisma.emailVerificationToken.count({ where: { usedAt: null } }); + expect(live).toBe(1); + + // The freshest code is the one emailed last. + const response = await post('/verify-email', { email: VALID.email, otp: code }); + expect(response.statusCode).toBe(200); + }); }); describe('GET /auth/me', () => {