This commit is contained in:
2025-12-31 19:55:43 +01:00
parent 8260bf7baf
commit 167e82a52b
66 changed files with 5124 additions and 228 deletions

View File

@@ -0,0 +1,236 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { ForgotPasswordUseCase } from './ForgotPasswordUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type ForgotPasswordOutput = {
message: string;
magicLink?: string | null;
};
describe('ForgotPasswordUseCase', () => {
let authRepo: {
findByEmail: Mock;
save: Mock;
};
let magicLinkRepo: {
checkRateLimit: Mock;
createPasswordResetRequest: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<ForgotPasswordOutput> & { present: Mock };
let useCase: ForgotPasswordUseCase;
beforeEach(() => {
authRepo = {
findByEmail: vi.fn(),
save: vi.fn(),
};
magicLinkRepo = {
checkRateLimit: vi.fn(),
createPasswordResetRequest: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new ForgotPasswordUseCase(
authRepo as unknown as IAuthRepository,
magicLinkRepo as unknown as IMagicLinkRepository,
logger,
output,
);
});
it('should create magic link for existing user', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(magicLinkRepo.checkRateLimit).toHaveBeenCalledWith(input.email);
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
expect(output.present).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
});
it('should return success for non-existent email (security)', async () => {
const input = { email: 'nonexistent@example.com' };
authRepo.findByEmail.mockResolvedValue(null);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
expect(output.present).toHaveBeenCalledWith({
message: 'If an account exists with this email, a password reset link will be sent',
magicLink: null,
});
expect(result.isOk()).toBe(true);
});
it('should handle rate limiting', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limited' } })
);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
});
it('should validate email format', async () => {
const input = { email: 'invalid-email' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
});
it('should generate secure tokens', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
let capturedToken: string | undefined;
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
capturedToken = data.token;
return Promise.resolve();
});
await useCase.execute(input);
expect(capturedToken).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
});
it('should set correct expiration time (15 minutes)', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const beforeCreate = Date.now();
let capturedExpiresAt: Date | undefined;
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
capturedExpiresAt = data.expiresAt;
return Promise.resolve();
});
await useCase.execute(input);
const afterCreate = Date.now();
expect(capturedExpiresAt).toBeDefined();
const timeDiff = capturedExpiresAt!.getTime() - afterCreate;
// Should be approximately 15 minutes (900000ms)
expect(timeDiff).toBeGreaterThan(890000);
expect(timeDiff).toBeLessThan(910000);
});
it('should return magic link in development mode', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
await useCase.execute(input);
expect(output.present).toHaveBeenCalledWith(
expect.objectContaining({
magicLink: expect.stringContaining('token='),
})
);
process.env.NODE_ENV = originalEnv ?? 'test';
});
it('should not return magic link in production mode', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
await useCase.execute(input);
expect(output.present).toHaveBeenCalledWith(
expect.objectContaining({
magicLink: null,
})
);
process.env.NODE_ENV = originalEnv ?? 'test';
});
it('should handle repository errors', async () => {
const input = { email: 'test@example.com' };
authRepo.findByEmail.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toContain('Database error');
});
});

View File

@@ -0,0 +1,132 @@
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import { randomBytes } from 'crypto';
export type ForgotPasswordInput = {
email: string;
};
export type ForgotPasswordResult = {
message: string;
magicLink?: string | null; // For development/demo purposes
};
export type ForgotPasswordErrorCode = 'USER_NOT_FOUND' | 'REPOSITORY_ERROR' | 'RATE_LIMIT_EXCEEDED';
export type ForgotPasswordApplicationError = ApplicationErrorCode<ForgotPasswordErrorCode, { message: string }>;
/**
* Application Use Case: ForgotPasswordUseCase
*
* Handles password reset requests by generating magic links.
* In production, this would send an email with the magic link.
* In development, it returns the link for testing purposes.
*/
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void, ForgotPasswordErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly magicLinkRepo: IMagicLinkRepository,
private readonly notificationPort: IMagicLinkNotificationPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ForgotPasswordResult>,
) {}
async execute(input: ForgotPasswordInput): Promise<Result<void, ForgotPasswordApplicationError>> {
try {
// Validate email format
const emailVO = EmailAddress.create(input.email);
// Check if user exists
const user = await this.authRepo.findByEmail(emailVO);
// Check rate limiting (implement in repository) - even if user doesn't exist
const rateLimitResult = await this.magicLinkRepo.checkRateLimit(input.email);
if (rateLimitResult.isErr()) {
return Result.err({
code: 'RATE_LIMIT_EXCEEDED',
details: { message: 'Too many reset attempts. Please try again later.' },
});
}
// If user exists, generate magic link
if (user) {
// Generate secure token
const token = this.generateSecureToken();
// Set expiration (15 minutes)
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
// Store magic link
await this.magicLinkRepo.createPasswordResetRequest({
email: input.email,
token,
expiresAt,
userId: user.getId().value,
});
// Generate magic link URL
const magicLink = this.generateMagicLink(token);
this.logger.info('[ForgotPasswordUseCase] Magic link generated', {
email: input.email,
userId: user.getId().value,
expiresAt,
});
// Send notification via port
await this.notificationPort.sendMagicLink({
email: input.email,
magicLink,
userId: user.getId().value,
expiresAt,
});
this.output.present({
message: 'Password reset link generated successfully',
magicLink: process.env.NODE_ENV === 'development' ? magicLink : null,
});
} else {
// User not found - still return success for security (prevents email enumeration)
this.logger.info('[ForgotPasswordUseCase] User not found, but returning success for security', {
email: input.email,
});
this.output.present({
message: 'If an account exists with this email, a password reset link will be sent',
magicLink: null,
});
}
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute ForgotPasswordUseCase';
this.logger.error('ForgotPasswordUseCase.execute failed', error instanceof Error ? error : undefined, {
input,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
private generateSecureToken(): string {
// Generate 32-byte random token and convert to hex
return randomBytes(32).toString('hex');
}
private generateMagicLink(token: string): string {
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
return `${baseUrl}/auth/reset-password?token=${token}`;
}
}

View File

@@ -51,7 +51,6 @@ describe('GetCurrentSessionUseCase', () => {
email: 'test@example.com',
displayName: 'Test User',
passwordHash: 'hash',
salt: 'salt',
primaryDriverId: 'driver-123',
createdAt: new Date(),
};

View File

@@ -44,7 +44,6 @@ describe('GetUserUseCase', () => {
email: 'test@example.com',
displayName: 'Test User',
passwordHash: 'hash',
salt: 'salt',
primaryDriverId: 'driver-1',
createdAt: new Date(),
};

View File

@@ -57,20 +57,18 @@ describe('LoginWithEmailUseCase', () => {
password: 'password123',
};
// Import PasswordHash to create a proper hash
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create('password123');
const storedUser: StoredUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
passwordHash: '',
salt: 'salt',
passwordHash: passwordHash.value,
createdAt: new Date(),
};
storedUser.passwordHash = await (useCase as unknown as { hashPassword: (p: string, s: string) => Promise<string> }).hashPassword(
input.password,
storedUser.salt,
);
const session = {
user: {
id: storedUser.id,
@@ -141,12 +139,15 @@ describe('LoginWithEmailUseCase', () => {
password: 'wrong',
};
// Create a hash for a different password
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create('correct-password');
const storedUser: StoredUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
passwordHash: 'different-hash',
salt: 'salt',
passwordHash: passwordHash.value,
createdAt: new Date(),
};

View File

@@ -62,8 +62,12 @@ export class LoginWithEmailUseCase {
} as LoginWithEmailApplicationError);
}
const passwordHash = await this.hashPassword(input.password, user.salt);
if (passwordHash !== user.passwordHash) {
// Verify password using PasswordHash value object
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const storedPasswordHash = PasswordHash.fromHash(user.passwordHash);
const isValid = await storedPasswordHash.verify(input.password);
if (!isValid) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid email or password' },
@@ -117,23 +121,4 @@ export class LoginWithEmailUseCase {
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
// Simple hash for demo - in production, use bcrypt or argon2
const data = password + salt;
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Fallback for environments without crypto.subtle
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(16, '0');
}
}

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { ResetPasswordUseCase } from './ResetPasswordUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type ResetPasswordOutput = {
message: string;
};
describe('ResetPasswordUseCase', () => {
let authRepo: {
findByEmail: Mock;
save: Mock;
};
let magicLinkRepo: {
findByToken: Mock;
markAsUsed: Mock;
};
let passwordService: {
hash: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<ResetPasswordOutput> & { present: Mock };
let useCase: ResetPasswordUseCase;
beforeEach(() => {
authRepo = {
findByEmail: vi.fn(),
save: vi.fn(),
};
magicLinkRepo = {
findByToken: vi.fn(),
markAsUsed: vi.fn(),
};
passwordService = {
hash: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new ResetPasswordUseCase(
authRepo as unknown as IAuthRepository,
magicLinkRepo as unknown as IMagicLinkRepository,
passwordService as unknown as IPasswordHashingService,
logger,
output,
);
});
it('should reset password with valid token', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: 'test@example.com',
});
const resetRequest = {
email: 'test@example.com',
token: input.token,
expiresAt: new Date(Date.now() + 60000), // 1 minute from now
userId: user.getId().value,
};
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
authRepo.findByEmail.mockResolvedValue(user);
passwordService.hash.mockResolvedValue('hashed-new-password');
const result = await useCase.execute(input);
expect(magicLinkRepo.findByToken).toHaveBeenCalledWith(input.token);
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create('test@example.com'));
expect(passwordService.hash).toHaveBeenCalledWith(input.newPassword);
expect(authRepo.save).toHaveBeenCalled();
expect(magicLinkRepo.markAsUsed).toHaveBeenCalledWith(input.token);
expect(output.present).toHaveBeenCalledWith({
message: 'Password reset successfully. You can now log in with your new password.',
});
expect(result.isOk()).toBe(true);
});
it('should reject invalid token', async () => {
const input = {
token: 'invalid-token',
newPassword: 'NewPass123!',
};
magicLinkRepo.findByToken.mockResolvedValue(null);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_TOKEN');
});
it('should reject expired token', async () => {
const input = {
token: 'expired-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
const resetRequest = {
email: 'test@example.com',
token: input.token,
expiresAt: new Date(Date.now() - 60000), // 1 minute ago
userId: 'user-123',
};
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('EXPIRED_TOKEN');
});
it('should reject weak password', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'weak',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should reject password without uppercase', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'newpass123!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should reject password without number', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should reject password shorter than 8 characters', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'New1!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should handle user no longer exists', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
const resetRequest = {
email: 'deleted@example.com',
token: input.token,
expiresAt: new Date(Date.now() + 60000),
userId: 'user-123',
};
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
authRepo.findByEmail.mockResolvedValue(null);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_TOKEN');
});
it('should handle token format validation', async () => {
const input = {
token: 'short',
newPassword: 'NewPass123!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_TOKEN');
});
it('should handle repository errors', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
magicLinkRepo.findByToken.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toContain('Database error');
});
});

View File

@@ -0,0 +1,143 @@
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
export type ResetPasswordInput = {
token: string;
newPassword: string;
};
export type ResetPasswordResult = {
message: string;
};
export type ResetPasswordErrorCode = 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'WEAK_PASSWORD' | 'REPOSITORY_ERROR';
export type ResetPasswordApplicationError = ApplicationErrorCode<ResetPasswordErrorCode, { message: string }>;
/**
* Application Use Case: ResetPasswordUseCase
*
* Handles password reset using a magic link token.
* Validates token, checks expiration, and updates password.
*/
export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, void, ResetPasswordErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly magicLinkRepo: IMagicLinkRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ResetPasswordResult>,
) {}
async execute(input: ResetPasswordInput): Promise<Result<void, ResetPasswordApplicationError>> {
try {
// Validate token format
if (!input.token || typeof input.token !== 'string' || input.token.length < 32) {
return Result.err({
code: 'INVALID_TOKEN',
details: { message: 'Invalid reset token' },
});
}
// Validate password strength
if (!this.isPasswordStrong(input.newPassword)) {
return Result.err({
code: 'WEAK_PASSWORD',
details: { message: 'Password must be at least 8 characters and contain uppercase, lowercase, and number' },
});
}
// Find token
const resetRequest = await this.magicLinkRepo.findByToken(input.token);
if (!resetRequest) {
return Result.err({
code: 'INVALID_TOKEN',
details: { message: 'Invalid or expired reset token' },
});
}
// Check expiration
if (resetRequest.expiresAt < new Date()) {
return Result.err({
code: 'EXPIRED_TOKEN',
details: { message: 'Reset token has expired. Please request a new one.' },
});
}
// Find user by email
const emailVO = EmailAddress.create(resetRequest.email);
const user = await this.authRepo.findByEmail(emailVO);
if (!user) {
return Result.err({
code: 'INVALID_TOKEN',
details: { message: 'User no longer exists' },
});
}
// Hash new password
const hashedPassword = await this.passwordService.hash(input.newPassword);
// Create a new user instance with updated password
const UserModule = await import('../../domain/entities/User');
const passwordHash = PasswordHash.fromHash(hashedPassword);
const email = user.getEmail();
const iracingCustomerId = user.getIracingCustomerId();
const primaryDriverId = user.getPrimaryDriverId();
const avatarUrl = user.getAvatarUrl();
const updatedUserInstance = UserModule.User.rehydrate({
id: user.getId().value,
displayName: user.getDisplayName(),
...(email !== undefined ? { email } : {}),
passwordHash: passwordHash,
...(iracingCustomerId !== undefined ? { iracingCustomerId } : {}),
...(primaryDriverId !== undefined ? { primaryDriverId } : {}),
...(avatarUrl !== undefined ? { avatarUrl } : {}),
});
await this.authRepo.save(updatedUserInstance);
// Mark token as used
await this.magicLinkRepo.markAsUsed(input.token);
this.logger.info('[ResetPasswordUseCase] Password reset successful', {
userId: user.getId().value,
email: resetRequest.email,
});
this.output.present({
message: 'Password reset successfully. You can now log in with your new password.',
});
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute ResetPasswordUseCase';
this.logger.error('ResetPasswordUseCase.execute failed', error instanceof Error ? error : undefined, {
input,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
private isPasswordStrong(password: string): boolean {
if (password.length < 8) return false;
if (!/[a-z]/.test(password)) return false;
if (!/[A-Z]/.test(password)) return false;
if (!/\d/.test(password)) return false;
return true;
}
}

View File

@@ -17,7 +17,7 @@ export type SignupResult = {
user: User;
};
export type SignupErrorCode = 'USER_ALREADY_EXISTS' | 'REPOSITORY_ERROR';
export type SignupErrorCode = 'USER_ALREADY_EXISTS' | 'WEAK_PASSWORD' | 'INVALID_DISPLAY_NAME' | 'REPOSITORY_ERROR';
export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { message: string }>;
@@ -36,8 +36,18 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
try {
// Validate email format
const emailVO = EmailAddress.create(input.email);
// Validate password strength
if (!this.isPasswordStrong(input.password)) {
return Result.err({
code: 'WEAK_PASSWORD',
details: { message: 'Password must be at least 8 characters and contain uppercase, lowercase, and number' },
});
}
// Check if user exists
const existingUser = await this.authRepo.findByEmail(emailVO);
if (existingUser) {
return Result.err({
@@ -46,10 +56,12 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
});
}
// Hash password
const hashedPassword = await this.passwordService.hash(input.password);
const passwordHashModule = await import('../../domain/value-objects/PasswordHash');
const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword);
// Create user (displayName validation happens in User entity constructor)
const userId = UserId.create();
const user = User.create({
id: userId,
@@ -63,6 +75,18 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
this.output.present({ user });
return Result.ok(undefined);
} catch (error) {
// Handle specific validation errors from User entity
if (error instanceof Error) {
if (error.message.includes('Name must be at least') ||
error.message.includes('Name can only contain') ||
error.message.includes('Please use your real name')) {
return Result.err({
code: 'INVALID_DISPLAY_NAME',
details: { message: error.message },
});
}
}
const message =
error instanceof Error && error.message
? error.message
@@ -78,4 +102,12 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
});
}
}
private isPasswordStrong(password: string): boolean {
if (password.length < 8) return false;
if (!/[a-z]/.test(password)) return false;
if (!/[A-Z]/.test(password)) return false;
if (!/\d/.test(password)) return false;
return true;
}
}

View File

@@ -142,7 +142,6 @@ describe('SignupWithEmailUseCase', () => {
email: command.email,
displayName: command.displayName,
passwordHash: 'hash',
salt: 'salt',
createdAt: new Date(),
};

View File

@@ -84,9 +84,9 @@ export class SignupWithEmailUseCase {
}
try {
// Hash password (simple hash for demo - in production use bcrypt)
const salt = this.generateSalt();
const passwordHash = await this.hashPassword(input.password, salt);
// Hash password using PasswordHash value object
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create(input.password);
// Create user
const userId = this.generateUserId();
@@ -95,8 +95,7 @@ export class SignupWithEmailUseCase {
id: userId,
email: input.email.toLowerCase().trim(),
displayName: input.displayName.trim(),
passwordHash,
salt,
passwordHash: passwordHash.value,
createdAt,
};
@@ -142,38 +141,6 @@ export class SignupWithEmailUseCase {
}
}
private generateSalt(): string {
const array = new Uint8Array(16);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
} else {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
private async hashPassword(password: string, salt: string): Promise<string> {
// Simple hash for demo - in production, use bcrypt or argon2
const data = password + salt;
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Fallback for environments without crypto.subtle
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(16, '0');
}
private generateUserId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();

View File

@@ -24,12 +24,10 @@ export class User {
private avatarUrl: string | undefined;
private constructor(props: UserProps) {
if (!props.displayName || !props.displayName.trim()) {
throw new Error('User displayName cannot be empty');
}
this.validateDisplayName(props.displayName);
this.id = props.id;
this.displayName = props.displayName.trim();
this.displayName = this.formatDisplayName(props.displayName);
this.email = props.email;
this.passwordHash = props.passwordHash;
this.iracingCustomerId = props.iracingCustomerId;
@@ -37,6 +35,56 @@ export class User {
this.avatarUrl = props.avatarUrl;
}
private validateDisplayName(displayName: string): void {
const trimmed = displayName?.trim();
if (!trimmed) {
throw new Error('Display name cannot be empty');
}
if (trimmed.length < 2) {
throw new Error('Name must be at least 2 characters long');
}
if (trimmed.length > 50) {
throw new Error('Name must be no more than 50 characters');
}
// Only allow letters, spaces, hyphens, and apostrophes
if (!/^[A-Za-z\s\-']+$/.test(trimmed)) {
throw new Error('Name can only contain letters, spaces, hyphens, and apostrophes');
}
// Block common nickname patterns
const blockedPatterns = [
/^user/i,
/^test/i,
/^demo/i,
/^[a-z0-9_]+$/i, // No alphanumeric-only (likely username/nickname)
/^guest/i,
/^player/i
];
if (blockedPatterns.some(pattern => pattern.test(trimmed))) {
throw new Error('Please use your real name (first and last name), not a nickname or username');
}
// Check for excessive spaces or repeated characters
if (/\s{2,}/.test(trimmed)) {
throw new Error('Name cannot contain multiple consecutive spaces');
}
if (/(.)\1{2,}/.test(trimmed)) {
throw new Error('Name cannot contain excessive repeated characters');
}
}
private formatDisplayName(displayName: string): string {
const trimmed = displayName.trim();
// Capitalize first letter of each word
return trimmed.replace(/\b\w/g, char => char.toUpperCase());
}
public static create(props: UserProps): User {
if (props.email) {
const result: EmailValidationResult = validateEmail(props.email);
@@ -128,4 +176,21 @@ export class User {
public getAvatarUrl(): string | undefined {
return this.avatarUrl;
}
/**
* Update display name - NOT ALLOWED after initial creation
* This method will always throw an error to enforce immutability
*/
public updateDisplayName(): void {
throw new Error('Display name cannot be changed after account creation. Please contact support if you need to update your name.');
}
/**
* Check if this user was created with a valid real name
* Used to verify immutability for existing users
*/
public hasImmutableName(): boolean {
// All users created through proper channels have immutable names
return true;
}
}

View File

@@ -0,0 +1,20 @@
/**
* Port for sending magic link notifications
* In production, this would send emails
* In development, it can log to console or return the link
*/
export interface MagicLinkNotificationInput {
email: string;
magicLink: string;
userId: string;
expiresAt: Date;
}
export interface IMagicLinkNotificationPort {
/**
* Send a magic link notification to the user
* @param input - The notification data
* @returns Promise<void>
*/
sendMagicLink(input: MagicLinkNotificationInput): Promise<void>;
}

View File

@@ -0,0 +1,37 @@
import { Result } from '@core/shared/application/Result';
export interface PasswordResetRequest {
email: string;
token: string;
expiresAt: Date;
userId: string;
used?: boolean;
}
export interface IMagicLinkRepository {
/**
* Create a password reset request
*/
createPasswordResetRequest(request: PasswordResetRequest): Promise<void>;
/**
* Find a password reset request by token
*/
findByToken(token: string): Promise<PasswordResetRequest | null>;
/**
* Mark a token as used
*/
markAsUsed(token: string): Promise<void>;
/**
* Check rate limiting for an email
* Returns Result.ok if allowed, Result.err if rate limited
*/
checkRateLimit(email: string): Promise<Result<void, { message: string }>>;
/**
* Clean up expired tokens
*/
cleanupExpired(): Promise<void>;
}

View File

@@ -7,7 +7,6 @@
export interface UserCredentials {
email: string;
passwordHash: string;
salt: string;
}
export interface StoredUser {
@@ -15,7 +14,7 @@ export interface StoredUser {
email: string;
displayName: string;
passwordHash: string;
salt: string;
salt?: string;
primaryDriverId?: string | undefined;
createdAt: Date;
}

View File

@@ -10,6 +10,8 @@ export * from './domain/repositories/IUserRepository';
export * from './domain/repositories/ISponsorAccountRepository';
export * from './domain/repositories/IUserRatingRepository';
export * from './domain/repositories/IAchievementRepository';
export * from './domain/repositories/IAuthRepository';
export * from './domain/repositories/IMagicLinkRepository';
export * from './application/ports/IdentityProviderPort';
export * from './application/ports/IdentitySessionPort';
@@ -18,3 +20,7 @@ export * from './application/use-cases/StartAuthUseCase';
export * from './application/use-cases/HandleAuthCallbackUseCase';
export * from './application/use-cases/GetCurrentUserSessionUseCase';
export * from './application/use-cases/LogoutUseCase';
export * from './application/use-cases/SignupUseCase';
export * from './application/use-cases/LoginUseCase';
export * from './application/use-cases/ForgotPasswordUseCase';
export * from './application/use-cases/ResetPasswordUseCase';