Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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'
}));
});
});
83 changes: 83 additions & 0 deletions backend/src/auth/application/useCases/RegisterUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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");
userExists.emailVerificationToken = crypto.createHash("sha256").update(rawToken).digest("hex");
userExists.emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);

await this.userRepository.save(userExists);

const verificationUrl = `${frontendUrl}/verify-email?token=${rawToken}`;
await this.emailService.sendVerificationEmail(userExists.email, verificationUrl);
}
// 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) {
// If it fails due to a duplicate-email constraint race condition,
// fall back to the existing user response flow.
return { alreadyRegistered: true };
}
throw error;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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