Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/routes/authRoutes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const express = require("express");
const { registerUser, loginUser, verifyEmail, resendVerificationEmail, getUserProfile, updateUserProfile, changePassword, deleteUserAccount, refreshToken, logoutUser } = require("../controllers/authController");
const { verifyEmail, resendVerificationEmail, getUserProfile, updateUserProfile, changePassword, deleteUserAccount, refreshToken, logoutUser } = require("../controllers/authController");
const { authController: hexAuthController } = require("../src/auth/index");
const { protect } = require("../middlewares/authMiddleware");
const { upload, validateImageUpload } = require("../middlewares/uploadMiddleware");
const { validateUserLogin, validateUserSignup, validateRefreshToken, validateResendEmail } = require("../Input_validators/ValidateAuth");
Expand All @@ -19,8 +20,8 @@ const {
// that authenticate off the ambient refreshToken cookie.

// Auth Routes
router.post("/register", authLimiter, validateUserSignup, registerUser);
router.post("/login", loginLimiter, validateUserLogin, loginUser);
router.post("/register", authLimiter, validateUserSignup, hexAuthController.registerUser);
router.post("/login", loginLimiter, validateUserLogin, hexAuthController.loginUser);

// Frontend should GET this once on app load to prime the XSRF-TOKEN cookie
// before it ever needs to call /refresh or /logout.
Expand Down
6 changes: 6 additions & 0 deletions backend/src/auth/application/ports/IEmailService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class IEmailService {
async sendVerificationEmail(to, verificationUrl) {
throw new Error("Method not implemented.");
}
}
module.exports = IEmailService;
28 changes: 28 additions & 0 deletions backend/src/auth/application/ports/IUserRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Interface representing the outbound port for User persistence.
* Note: Since JS does not have interfaces, this class serves as documentation
* and throws "Not Implemented" errors if methods are not overridden.
*/
class IUserRepository {
async findByEmail(email) {
throw new Error("Method not implemented.");
}

async findByPrepPilotId(prepPilotId) {
throw new Error("Method not implemented.");
}

async findById(id) {
throw new Error("Method not implemented.");
}

async findByVerificationToken(token) {
throw new Error("Method not implemented.");
}

async save(userEntity) {
throw new Error("Method not implemented.");
}
}

module.exports = IUserRepository;
44 changes: 44 additions & 0 deletions backend/src/auth/application/useCases/LoginUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const bcrypt = require("bcryptjs");
const { ValidationError } = require("../../../shared/domain/BaseError");

class LoginUser {
constructor(userRepository, tokenService) {
this.userRepository = userRepository;
this.tokenService = tokenService;
}

async execute({ email, password }) {
if (!email || !password) {
throw new ValidationError("Email and password are required.");
}

const cleanEmail = email.trim().toLowerCase();
const user = await this.userRepository.findByEmail(cleanEmail);

if (!user) {
return { success: false, reason: "invalid_credentials" };
}

const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return { success: false, reason: "invalid_credentials" };
}

if (!user.isEmailVerified) {
return { success: false, reason: "email_not_verified" };
}

// Generate tokens
const accessToken = this.tokenService.generateAccessToken(user.id, user.tokenVersion);
const refreshToken = this.tokenService.generateRefreshToken(user.id);

user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
user.refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

await this.userRepository.save(user);

return { success: true, user, accessToken, refreshToken };
}
}

module.exports = LoginUser;
62 changes: 62 additions & 0 deletions backend/src/auth/application/useCases/LoginUser.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const LoginUser = require('./LoginUser');
const { ValidationError } = require('../../../shared/domain/BaseError');
const bcrypt = require('bcryptjs');

describe('LoginUser Use Case', () => {
let userRepositoryMock;
let tokenServiceMock;
let loginUser;

beforeEach(() => {
userRepositoryMock = {
findByEmail: vi.fn(),
save: vi.fn(),
};
tokenServiceMock = {
generateAccessToken: vi.fn().mockReturnValue('access-token'),
generateRefreshToken: vi.fn().mockReturnValue('refresh-token'),
};
loginUser = new LoginUser(userRepositoryMock, tokenServiceMock);
});

it('should throw ValidationError if email or password missing', async () => {
await expect(loginUser.execute({ email: 'a@b.com' })).rejects.toThrow(ValidationError);
await expect(loginUser.execute({ password: 'pwd' })).rejects.toThrow(ValidationError);
});

it('should return invalid_credentials if user not found', async () => {
userRepositoryMock.findByEmail.mockResolvedValue(null);
const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' });
expect(res).toEqual({ success: false, reason: 'invalid_credentials' });
});

it('should return invalid_credentials if password mismatch', async () => {
userRepositoryMock.findByEmail.mockResolvedValue({ password: 'hashed' });
vi.spyOn(bcrypt, 'compare').mockResolvedValue(false);
const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' });
expect(res).toEqual({ success: false, reason: 'invalid_credentials' });
});

it('should return email_not_verified if not verified', async () => {
userRepositoryMock.findByEmail.mockResolvedValue({ password: 'hashed', isEmailVerified: false });
vi.spyOn(bcrypt, 'compare').mockResolvedValue(true);
const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' });
expect(res).toEqual({ success: false, reason: 'email_not_verified' });
});

it('should return success and tokens if verified', async () => {
const user = { id: '123', password: 'hashed', isEmailVerified: true, tokenVersion: 1 };
userRepositoryMock.findByEmail.mockResolvedValue(user);
vi.spyOn(bcrypt, 'compare').mockResolvedValue(true);
vi.spyOn(bcrypt, 'hash').mockResolvedValue('refresh-hash');

const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' });
expect(res.success).toBe(true);
expect(res.accessToken).toBe('access-token');
expect(res.refreshToken).toBe('refresh-token');
expect(userRepositoryMock.save).toHaveBeenCalledWith(expect.objectContaining({
refreshTokenHash: 'refresh-hash'
}));
});
});
88 changes: 88 additions & 0 deletions backend/src/auth/application/useCases/RegisterUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const crypto = require("crypto");
const bcrypt = require("bcryptjs");
const UserEntity = require("../../domain/entities/UserEntity");
const { ConflictError, ValidationError } = require("../../../shared/domain/BaseError");

class RegisterUser {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
this.PASSWORD_SALT_ROUNDS = 10; // Ideally configurable via env
}

async execute({ name, email, password, frontendUrl }) {
const cleanName = name.trim();
const cleanEmail = email.trim().toLowerCase();

const emailRegex = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
if (!emailRegex.test(cleanEmail)) {
throw new ValidationError("Please enter a valid email address.");
}

// Check if user exists
let userExists = await this.userRepository.findByEmail(cleanEmail);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (userExists) {
// Existing user handling
if (!userExists.isEmailVerified) {
const rawToken = crypto.randomBytes(32).toString("hex");
const hashedToken = crypto.createHash("sha256").update(rawToken).digest("hex");
userExists.emailVerificationToken = hashedToken;
userExists.emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);

await this.userRepository.save(userExists);

// Outbox/version check to prevent delivery of superseded tokens
const latestUser = await this.userRepository.findByEmail(cleanEmail);
if (latestUser && latestUser.emailVerificationToken === hashedToken) {
const verificationUrl = `${frontendUrl}/verify-email?token=${rawToken}`;
await this.emailService.sendVerificationEmail(userExists.email, verificationUrl);
}
Comment on lines +33 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | πŸ”΄ Critical | πŸ—οΈ Heavy lift

Make token freshness atomic with email delivery.

The save at Line 33, re-read at Line 36, and email send at Line 39 are separate operations. A concurrent request can replace the token after Line 36 returns and before Line 39 runs. The first request can then send a token that verification no longer accepts.

Use a repository/email port operation that couples token versioning to delivery, or keep each unexpired verification token valid until expiry. Add a concurrency test for this interleaving.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/auth/application/useCases/RegisterUser.js` around lines 33 - 40,
Make token freshness and verification-email delivery atomic in the registration
flow around the user repository save and emailService.sendVerificationEmail
call. Replace the separate findByEmail check with a repository/email-port
operation that conditionally delivers only when the saved token version remains
current, or preserve every unexpired verification token until expiry; add a
concurrency test covering a token replacement between the freshness check and
delivery.

}
// Return true indicating process completed but email was already used (to avoid enumeration)
return { alreadyRegistered: true };
}

// New user creation
const hashedPassword = await bcrypt.hash(password, this.PASSWORD_SALT_ROUNDS);

const nameParts = cleanName.split(/\s+/);
const firstName = nameParts[0] || "";
const lastName = nameParts.slice(1).join(" ") || "";
const defaultPrepPilotId = cleanEmail.split("@")[0] + Math.floor(1000 + Math.random() * 9000);

const rawToken = crypto.randomBytes(32).toString("hex");
const emailVerificationToken = crypto.createHash("sha256").update(rawToken).digest("hex");
const emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);

const newUser = new UserEntity({
name: cleanName,
email: cleanEmail,
password: hashedPassword,
firstName,
lastName,
prepPilotId: defaultPrepPilotId,
isEmailVerified: false,
emailVerificationToken,
emailVerificationExpires
});

try {
await this.userRepository.save(newUser);
} catch (error) {
if (error instanceof ConflictError && Object.keys(error.fields || {}).includes("email")) {
// If it fails due to a duplicate-email constraint race condition,
// fall back to the existing user response flow.
return { alreadyRegistered: true };
}
throw error;
}

const verificationUrl = `${frontendUrl}/verify-email?token=${rawToken}`;
await this.emailService.sendVerificationEmail(newUser.email, verificationUrl);

return { alreadyRegistered: false };
}
}

module.exports = RegisterUser;
60 changes: 60 additions & 0 deletions backend/src/auth/application/useCases/RegisterUser.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const RegisterUser = require('./RegisterUser');
const { ValidationError } = require('../../../shared/domain/BaseError');

describe('RegisterUser Use Case', () => {
let userRepositoryMock;
let emailServiceMock;
let registerUser;

beforeEach(() => {
userRepositoryMock = {
findByEmail: vi.fn(),
save: vi.fn(),
};
emailServiceMock = {
sendVerificationEmail: vi.fn(),
};
registerUser = new RegisterUser(userRepositoryMock, emailServiceMock);
});

it('should throw ValidationError if email is invalid', async () => {
const req = { name: 'John', email: 'invalid-email', password: 'pwd', frontendUrl: 'http://test' };
await expect(registerUser.execute(req)).rejects.toThrow(ValidationError);
});

it('should handle existing user without revealing it (alreadyRegistered: true)', async () => {
userRepositoryMock.findByEmail.mockResolvedValue({ isEmailVerified: true });
const req = { name: 'John', email: 'john@example.com', password: 'pwd', frontendUrl: 'http://test' };

const result = await registerUser.execute(req);

expect(result.alreadyRegistered).toBe(true);
expect(userRepositoryMock.save).not.toHaveBeenCalled();
});

it('should resend verification if existing user is unverified', async () => {
const existingUser = { email: 'john@example.com', isEmailVerified: false };
userRepositoryMock.findByEmail.mockResolvedValue(existingUser);
userRepositoryMock.save.mockResolvedValue(existingUser);
const req = { name: 'John', email: 'john@example.com', password: 'pwd', frontendUrl: 'http://test' };

const result = await registerUser.execute(req);

expect(result.alreadyRegistered).toBe(true);
expect(userRepositoryMock.save).toHaveBeenCalled();
expect(emailServiceMock.sendVerificationEmail).toHaveBeenCalled();
});

it('should create new user and send email (alreadyRegistered: false)', async () => {
userRepositoryMock.findByEmail.mockResolvedValue(null);
userRepositoryMock.save.mockResolvedValue();
const req = { name: 'John', email: 'john@example.com', password: 'pwd', frontendUrl: 'http://test' };

const result = await registerUser.execute(req);

expect(result.alreadyRegistered).toBe(false);
expect(userRepositoryMock.save).toHaveBeenCalled();
expect(emailServiceMock.sendVerificationEmail).toHaveBeenCalled();
});
});
Loading
Loading