Files
rentall-app/backend/routes/auth.js
2025-11-26 14:25:49 -05:00

926 lines
26 KiB
JavaScript

const express = require("express");
const jwt = require("jsonwebtoken");
const { OAuth2Client } = require("google-auth-library");
const { User, AlphaInvitation } = require("../models"); // Import from models/index.js to get models with associations
const logger = require("../utils/logger");
const emailServices = require("../services/email");
const crypto = require("crypto");
const {
sanitizeInput,
validateRegistration,
validateLogin,
validateGoogleAuth,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
} = require("../middleware/validation");
const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
const {
loginLimiter,
registerLimiter,
passwordResetLimiter,
} = require("../middleware/rateLimiter");
const router = express.Router();
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback"
);
// Get CSRF token endpoint
router.get("/csrf-token", (req, res) => {
getCSRFToken(req, res);
});
router.post(
"/register",
registerLimiter,
csrfProtection,
sanitizeInput,
validateRegistration,
async (req, res) => {
try {
const { email, password, firstName, lastName, phone } =
req.body;
const existingUser = await User.findOne({
where: { email },
});
if (existingUser) {
return res.status(400).json({
error: "Registration failed",
details: [
{
field: "email",
message: "An account with this email already exists",
},
],
});
}
// Alpha access validation
let alphaInvitation = null;
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
if (req.cookies && req.cookies.alphaAccessCode) {
const { code } = req.cookies.alphaAccessCode;
if (code) {
alphaInvitation = await AlphaInvitation.findOne({
where: { code },
});
if (!alphaInvitation) {
return res.status(403).json({
error: "Invalid alpha access code",
});
}
if (alphaInvitation.status === "revoked") {
return res.status(403).json({
error: "This alpha access code is no longer valid",
});
}
}
}
if (!alphaInvitation) {
return res.status(403).json({
error: "Alpha access required. Please enter your invitation code first.",
});
}
}
const user = await User.create({
email,
password,
firstName,
lastName,
phone,
});
// Link alpha invitation to user
await alphaInvitation.update({
usedBy: user.id,
usedAt: new Date(),
status: "active",
});
// Generate verification token and send email
await user.generateVerificationToken();
// Send verification email (don't block registration if email fails)
let verificationEmailSent = false;
try {
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
verificationEmailSent = true;
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send verification email", {
error: emailError.message,
userId: user.id,
email: user.email,
});
// Continue with registration even if email fails
}
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User registration successful", {
userId: user.id,
email: user.email,
});
res.status(201).json({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isVerified: user.isVerified,
role: user.role,
},
verificationEmailSent,
// Don't send token in response body for security
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Registration error", {
error: error.message,
stack: error.stack,
email: req.body.email,
});
res.status(500).json({ error: "Registration failed. Please try again." });
}
}
);
router.post(
"/login",
loginLimiter,
csrfProtection,
sanitizeInput,
validateLogin,
async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Check if account is locked
if (user.isLocked()) {
return res.status(423).json({
error:
"Account is temporarily locked due to too many failed login attempts. Please try again later.",
});
}
// Verify password
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
// Increment login attempts
await user.incLoginAttempts();
return res.status(401).json({ error: "Invalid credentials" });
}
// Reset login attempts on successful login
await user.resetLoginAttempts();
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User login successful", {
userId: user.id,
email: user.email,
});
res.json({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isVerified: user.isVerified,
role: user.role,
},
// Don't send token in response body for security
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Login error", {
error: error.message,
stack: error.stack,
email: req.body.email,
});
res.status(500).json({ error: "Login failed. Please try again." });
}
}
);
router.post(
"/google",
loginLimiter,
csrfProtection,
sanitizeInput,
validateGoogleAuth,
async (req, res) => {
try {
const { code } = req.body;
if (!code) {
return res
.status(400)
.json({ error: "Authorization code is required" });
}
// Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({
code,
redirect_uri:
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback",
});
// Verify the ID token from the token response
const ticket = await googleClient.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
const {
sub: googleId,
email,
given_name: givenName,
family_name: familyName,
picture,
} = payload;
if (!email) {
return res.status(400).json({
error: "Email permission is required to continue. Please grant email access when signing in with Google and try again."
});
}
// Handle cases where Google doesn't provide name fields
// Generate fallback values from email or use placeholder
let firstName = givenName;
let lastName = familyName;
if (!firstName || !lastName) {
const emailUsername = email.split('@')[0];
// Try to split email username by common separators
const nameParts = emailUsername.split(/[._-]/);
if (!firstName) {
firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : 'Google';
}
if (!lastName) {
lastName = nameParts.length > 1
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
: 'User';
}
}
// Check if user exists by Google ID first
let user = await User.findOne({
where: { providerId: googleId, authProvider: "google" },
});
if (!user) {
// Check if user exists with same email but different auth provider
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(409).json({
error:
"An account with this email already exists. Please use email/password login.",
});
}
// Create new user (Google OAuth users are auto-verified)
user = await User.create({
email,
firstName,
lastName,
authProvider: "google",
providerId: googleId,
profileImage: picture,
isVerified: true,
verifiedAt: new Date(),
});
// Check if there's an alpha invitation for this email
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
const alphaInvitation = await AlphaInvitation.findOne({
where: { email: email.toLowerCase().trim() },
});
if (alphaInvitation && !alphaInvitation.usedBy) {
// Link invitation to new user
await alphaInvitation.update({
usedBy: user.id,
usedAt: new Date(),
status: "active",
});
}
}
}
// Generate JWT tokens
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 15 * 60 * 1000,
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Google authentication successful", {
userId: user.id,
email: user.email,
isNewUser:
!user.createdAt ||
Date.now() - new Date(user.createdAt).getTime() < 1000,
});
res.json({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
profileImage: user.profileImage,
isVerified: user.isVerified,
role: user.role,
},
// Don't send token in response body for security
});
} catch (error) {
if (error.message && error.message.includes("invalid_grant")) {
return res.status(401).json({
error: "Invalid or expired authorization code. Please try again.",
});
}
if (error.message && error.message.includes("redirect_uri_mismatch")) {
return res
.status(400)
.json({ error: "Redirect URI mismatch. Please contact support." });
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Google OAuth error", {
error: error.message,
stack: error.stack,
codePresent: !!req.body.code,
});
res
.status(500)
.json({ error: "Google authentication failed. Please try again." });
}
}
);
// Email verification endpoint
router.post("/verify-email", sanitizeInput, async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
error: "Verification token required",
code: "TOKEN_REQUIRED",
});
}
// Find user with this verification token
const user = await User.findOne({
where: { verificationToken: token },
});
if (!user) {
return res.status(400).json({
error: "Invalid verification token",
code: "VERIFICATION_TOKEN_INVALID",
});
}
// Check if already verified
if (user.isVerified) {
return res.status(400).json({
error: "Email already verified",
code: "ALREADY_VERIFIED",
});
}
// Check if token is valid (not expired)
if (!user.isVerificationTokenValid(token)) {
return res.status(400).json({
error: "Verification token has expired. Please request a new one.",
code: "VERIFICATION_TOKEN_EXPIRED",
});
}
// Verify the email
await user.verifyEmail();
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Email verified successfully", {
userId: user.id,
email: user.email,
});
res.json({
message: "Email verified successfully",
user: {
id: user.id,
email: user.email,
isVerified: true,
},
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Email verification error", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
error: "Email verification failed. Please try again.",
});
}
});
// Resend verification email endpoint
router.post(
"/resend-verification",
loginLimiter, // Use login limiter for rate limiting (max 3 per hour)
sanitizeInput,
async (req, res) => {
try {
// Get user from cookies
const { accessToken } = req.cookies;
if (!accessToken) {
return res.status(401).json({
error: "Authentication required",
code: "NO_TOKEN",
});
}
const decoded = jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET);
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(404).json({
error: "User not found",
code: "USER_NOT_FOUND",
});
}
// Check if already verified
if (user.isVerified) {
return res.status(400).json({
error: "Email already verified",
code: "ALREADY_VERIFIED",
});
}
// Generate new verification token
await user.generateVerificationToken();
// Send verification email
try {
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to resend verification email", {
error: emailError.message,
userId: user.id,
email: user.email,
});
return res.status(500).json({
error: "Failed to send verification email. Please try again.",
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Verification email resent", {
userId: user.id,
email: user.email,
});
res.json({
message: "Verification email sent successfully",
});
} catch (error) {
if (error.name === "TokenExpiredError") {
return res.status(401).json({
error: "Session expired. Please log in again.",
code: "TOKEN_EXPIRED",
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Resend verification error", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
error: "Failed to resend verification email. Please try again.",
});
}
}
);
// Refresh token endpoint
router.post("/refresh", async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: "Refresh token required" });
}
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
if (!decoded.id || decoded.type !== "refresh") {
return res.status(401).json({ error: "Invalid refresh token" });
}
// Find user
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(401).json({ error: "User not found" });
}
// Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) {
return res.status(401).json({
error: "Session expired due to password change. Please log in again.",
code: "JWT_VERSION_MISMATCH",
});
}
// Generate new access token
const newAccessToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
);
// Set new access token cookie
res.cookie("accessToken", newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict",
maxAge: 15 * 60 * 1000,
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Token refresh successful", {
userId: user.id,
});
res.json({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isVerified: user.isVerified,
role: user.role,
},
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Token refresh error", {
error: error.message,
stack: error.stack,
userId: req.user?.id,
});
res.status(401).json({ error: "Invalid or expired refresh token" });
}
});
// Logout endpoint
router.post("/logout", (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User logout", {
userId: req.user?.id || "anonymous",
});
// Clear cookies
res.clearCookie("accessToken");
res.clearCookie("refreshToken");
res.json({ message: "Logged out successfully" });
});
// Auth status check endpoint - returns 200 regardless of auth state
const { optionalAuth } = require("../middleware/auth");
router.get("/status", optionalAuth, async (req, res) => {
if (req.user) {
res.json({
authenticated: true,
user: req.user,
});
} else {
res.json({
authenticated: false,
});
}
});
// Forgot password endpoint
router.post(
"/forgot-password",
passwordResetLimiter,
csrfProtection,
sanitizeInput,
validateForgotPassword,
async (req, res) => {
try {
const { email } = req.body;
// Find user with local auth provider only
const user = await User.findOne({
where: {
email,
authProvider: "local",
},
});
// Always return success to prevent email enumeration
// Don't reveal whether the email exists or not
if (user) {
// Generate password reset token (returns plain token for email)
const resetToken = await user.generatePasswordResetToken();
// Send password reset email
try {
await emailServices.auth.sendPasswordResetEmail(user, resetToken);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Password reset email sent", {
userId: user.id,
email: user.email,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password reset email", {
error: emailError.message,
userId: user.id,
email: user.email,
});
// Continue - don't reveal email sending failure to user
}
} else {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info(
"Password reset requested for non-existent or OAuth user",
{
email: email,
}
);
}
// Always return success message (security best practice)
res.json({
message:
"If an account exists with that email, you will receive password reset instructions.",
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forgot password error", {
error: error.message,
stack: error.stack,
email: req.body.email,
});
res.status(500).json({
error: "Failed to process password reset request. Please try again.",
});
}
}
);
// Verify reset token endpoint (optional - for frontend UX)
router.post(
"/verify-reset-token",
sanitizeInput,
validateVerifyResetToken,
async (req, res) => {
try {
const { token } = req.body;
// Hash the token to search for it in the database
const hashedToken = crypto
.createHash("sha256")
.update(token)
.digest("hex");
// Find user with this reset token (hashed)
const user = await User.findOne({
where: { passwordResetToken: hashedToken },
});
if (!user) {
return res.status(400).json({
valid: false,
error: "Invalid reset token",
code: "TOKEN_INVALID",
});
}
// Check if token is valid (not expired)
if (!user.isPasswordResetTokenValid(token)) {
return res.status(400).json({
valid: false,
error: "Reset token has expired. Please request a new one.",
code: "TOKEN_EXPIRED",
});
}
res.json({
valid: true,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Verify reset token error", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
valid: false,
error: "Failed to verify reset token. Please try again.",
});
}
}
);
// Reset password endpoint
router.post(
"/reset-password",
passwordResetLimiter,
csrfProtection,
sanitizeInput,
validateResetPassword,
async (req, res) => {
try {
const { token, newPassword } = req.body;
// Hash the token to search for it in the database
const crypto = require("crypto");
const hashedToken = crypto
.createHash("sha256")
.update(token)
.digest("hex");
// Find user with this reset token (hashed)
const user = await User.findOne({
where: { passwordResetToken: hashedToken },
});
if (!user) {
return res.status(400).json({
error: "Invalid or expired reset token",
code: "TOKEN_INVALID",
});
}
// Check if token is valid (not expired)
if (!user.isPasswordResetTokenValid(token)) {
return res.status(400).json({
error: "Reset token has expired. Please request a new one.",
code: "TOKEN_EXPIRED",
});
}
// Reset password (this will clear the token and hash the new password)
await user.resetPassword(newPassword);
// Send password changed notification email
try {
await emailServices.auth.sendPasswordChangedEmail(user);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Password changed notification sent", {
userId: user.id,
email: user.email,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password changed notification", {
error: emailError.message,
userId: user.id,
email: user.email,
});
// Continue - don't fail password reset if email fails
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Password reset successful", {
userId: user.id,
email: user.email,
});
res.json({
message:
"Password has been reset successfully. You can now log in with your new password.",
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Reset password error", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
error: "Failed to reset password. Please try again.",
});
}
}
);
module.exports = router;