-
Notifications
You must be signed in to change notification settings - Fork 141
feat: implement authentication module using clean architecture with Login and Register use cases #1730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: implement authentication module using clean architecture with Login and Register use cases #1730
Changes from all commits
b3db17c
24b24e6
92350fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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; |
| 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; |
| 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' | ||
| })); | ||
| }); | ||
| }); |
| 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); | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| // 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; | ||
| 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(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.