feat: Add email verification and password reset

This commit is contained in:
Dries Augustyns
2025-12-20 20:06:25 +01:00
parent 7e25c148cc
commit 1a5607f278
31 changed files with 1026 additions and 122 deletions
+194 -2
View File
@@ -1,11 +1,25 @@
import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
import {randomBytes} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
import * as React from 'react';
import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js';
import {
DASHBOARD_URI,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {jwt} from '../middleware/auth.js';
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
import {jwt, parseJwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
@@ -64,6 +78,8 @@ export class Auth {
email,
password: await AuthService.generateHash(password),
type: 'PASSWORD',
// Auto-verify email if platform emails are disabled
emailVerified: !PLUNK_ENABLED,
},
});
@@ -72,6 +88,27 @@ export class Auth {
// Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
// Send email verification if platform emails are enabled
if (PLUNK_ENABLED) {
const verificationToken = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(verificationToken),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
);
const verificationUrl = `${LANDING_URI}/auth/verify-email?token=${verificationToken}`;
await sendPlatformEmail(
created_user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {
email: created_user.email,
verificationUrl,
landingUrl: LANDING_URI,
}),
);
}
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
@@ -97,4 +134,159 @@ export class Auth {
},
});
}
@Post('verify-email')
@CatchAsync
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
// Look up token in Redis
const data = await redis.get(Keys.User.emailVerificationToken(token));
if (!data) {
throw new BadRequest('Invalid or expired verification token');
}
const {userId} = JSON.parse(data);
// Update user
await prisma.user.update({
where: {id: userId},
data: {emailVerified: true},
});
// Delete token (single use) and invalidate cache
await redis.del(Keys.User.emailVerificationToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Email verified successfully'}});
}
@Post('request-verification')
@CatchAsync
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
const userId = parseJwt(req);
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
if (user.emailVerified) {
return res.json({success: true, data: {message: 'Email already verified'}});
}
// Check rate limit
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
throw new RateLimitError('Too many verification emails sent. Please try again later.');
}
// Generate token
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
);
// Send email
const verificationUrl = `${LANDING_URI}/auth/verify-email?token=${token}`;
await sendPlatformEmail(
user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
return res.json({success: true, data: {message: 'Verification email sent'}});
}
@Post('request-password-reset')
@CatchAsync
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
// Check rate limit
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
// Still return success to prevent enumeration
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
// Look up user
const user = await UserService.email(email);
// Only send email if user exists and is PASSWORD type
if (user && user.type === 'PASSWORD') {
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.passwordResetToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
);
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
await sendPlatformEmail(
user.email,
'Reset your password',
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
}
// Always return success (prevent enumeration)
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
@Post('reset-password')
@CatchAsync
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
// Look up token
const data = await redis.get(Keys.User.passwordResetToken(token));
if (!data) {
throw new BadRequest('Invalid or expired reset token');
}
const {userId} = JSON.parse(data);
// Get user and verify type
const user = await prisma.user.findUnique({where: {id: userId}});
if (!user || user.type !== 'PASSWORD') {
throw new BadRequest('Invalid reset token');
}
// Hash new password and update
const hashedPassword = await AuthService.generateHash(newPassword);
await prisma.user.update({
where: {id: userId},
data: {password: hashedPassword},
});
// Delete token and invalidate cache
await redis.del(Keys.User.passwordResetToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Password reset successfully'}});
}
}