331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
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 {
|
|
DASHBOARD_URI,
|
|
DISABLE_SIGNUPS,
|
|
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,
|
|
VERIFY_EMAIL_ON_SIGNUP,
|
|
} from '../app/constants.js';
|
|
import {prisma} from '../database/prisma.js';
|
|
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
|
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
|
|
import {jwt, parseJwt} from '../middleware/auth.js';
|
|
import {AuthService} from '../services/AuthService.js';
|
|
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
|
import {NtfyService} from '../services/NtfyService.js';
|
|
import {UserService} from '../services/UserService.js';
|
|
import {Keys} from '../services/keys.js';
|
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
|
|
|
@Controller('auth')
|
|
export class Auth {
|
|
@Post('login')
|
|
@CatchAsync
|
|
public async login(req: Request, res: Response, _next: NextFunction) {
|
|
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
|
|
|
const user = await UserService.email(email);
|
|
|
|
if (!user) {
|
|
return res.json({success: false, data: 'Incorrect email or password'});
|
|
}
|
|
|
|
if (user.type === 'PASSWORD' && !user.password) {
|
|
return res.json({success: 'redirect', redirect: `/auth/reset?id=${user.id}`});
|
|
}
|
|
|
|
const verified = await AuthService.verifyCredentials(email, password);
|
|
|
|
if (!verified) {
|
|
return res.json({success: false, data: 'Incorrect email or password'});
|
|
}
|
|
|
|
await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60);
|
|
|
|
const token = jwt.sign(user.id);
|
|
const cookie = UserService.cookieOptions();
|
|
|
|
return res
|
|
.cookie(UserService.COOKIE_NAME, token, cookie)
|
|
.json({success: true, data: {id: user.id, email: user.email}});
|
|
}
|
|
|
|
@Post('signup')
|
|
@CatchAsync
|
|
public async signup(req: Request, res: Response, _next: NextFunction) {
|
|
// Check if signups are disabled
|
|
if (DISABLE_SIGNUPS) {
|
|
return res.json({
|
|
success: false,
|
|
data: 'New user signups are currently disabled',
|
|
});
|
|
}
|
|
|
|
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
|
|
|
// Verify email is valid and not disposable/plus-addressed (if verification enabled)
|
|
if (VERIFY_EMAIL_ON_SIGNUP) {
|
|
const verification = await EmailVerificationService.verifyEmail(email);
|
|
|
|
if (
|
|
verification.isDisposable ||
|
|
verification.isPlusAddressed ||
|
|
!verification.domainExists ||
|
|
!verification.hasMxRecords
|
|
) {
|
|
// Build list of reasons for notification
|
|
const reasons: string[] = [];
|
|
if (verification.isDisposable) reasons.push('disposable email');
|
|
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
|
if (!verification.domainExists) reasons.push('domain does not exist');
|
|
if (!verification.hasMxRecords) reasons.push('no MX records');
|
|
|
|
// Send notification about failed signup attempt
|
|
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
|
|
|
return res.json({
|
|
success: false,
|
|
data: 'This email address cannot be used for signup',
|
|
});
|
|
}
|
|
}
|
|
|
|
const user = await UserService.email(email);
|
|
|
|
if (user) {
|
|
return res.json({
|
|
success: false,
|
|
data: 'That email is already associated with another user',
|
|
});
|
|
}
|
|
|
|
const created_user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
password: await AuthService.generateHash(password),
|
|
type: 'PASSWORD',
|
|
// Auto-verify email if platform emails are disabled
|
|
emailVerified: !PLUNK_ENABLED,
|
|
},
|
|
});
|
|
|
|
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);
|
|
|
|
// 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 = `${DASHBOARD_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();
|
|
|
|
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
|
|
success: true,
|
|
data: {id: created_user.id, email: created_user.email},
|
|
});
|
|
}
|
|
|
|
@Get('logout')
|
|
public logout(req: Request, res: Response) {
|
|
res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
|
|
return res.json(true);
|
|
}
|
|
|
|
@Get('oauth-config')
|
|
public oauthConfig(req: Request, res: Response) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
github: GITHUB_OAUTH_ENABLED,
|
|
google: GOOGLE_OAUTH_ENABLED,
|
|
},
|
|
});
|
|
}
|
|
|
|
@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 = `${DASHBOARD_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'}});
|
|
}
|
|
}
|