Skip to content
Merged
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
73 changes: 47 additions & 26 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ import { UserRole } from '../types/prisma.types';
import { FraudService } from '../fraud/fraud.service';

type JwtPayload = {
sub: string;
email: string;
role: UserRole;
type: 'access' | 'refresh';
jti: string;
family?: string;
exp?: number;
};
sub: string;
email: string;
role: UserRole;
type: 'access' | 'refresh';
jti: string;
family?: string;
exp?: number;
};

@Injectable()
export class AuthService {
Expand Down Expand Up @@ -264,40 +264,49 @@ export class AuthService {
}
}

async login(data: LoginDto, ipAddress?: string, userAgent?: string) {
await this.preflightChecks(data, ipAddress, userAgent);

const user = await this.usersService.findByEmail(data.email);
if (!user) {
// Record failed attempt even if user doesn't exist (prevent enumeration)
await this.rateLimitService.recordFailedAttempt(data.email, ipAddress, userAgent);
throw new UnauthorizedException('Invalid credentials');
}

/**
* Verify a fetched user's credentials: account state checks (blocked/
* deactivated/unverified) and bcrypt password comparison.
*
* Centralises the post-findByEmail gating plus the bcrypt compare and the
* rate-limit / fraud / lockout-email side effects. Extracted from login()
* for testability and readability (issue #744).
*
* @throws UnauthorizedException on any gate failure or password mismatch
*/
private async verifyCredentials(
user: {
id: string;
email: string;
password: string | null;
isBlocked: boolean;
isDeactivated: boolean;
isVerified: boolean;
},
password: string,
ipAddress?: string,
userAgent?: string,
): Promise<void> {
if (user.isBlocked) {
throw new UnauthorizedException('Your account has been blocked. Please contact support.');
}

if (user.isDeactivated) {
throw new UnauthorizedException(
'Your account has been deactivated. Please contact support to reactivate your account.',
);
}

if (!user.isVerified) {
throw new UnauthorizedException('Please verify your email before logging in.');
}

const passwordMatches = await comparePassword(data.password, user.password ?? '');
const passwordMatches = await comparePassword(password, user.password ?? '');
if (!passwordMatches) {
// Record failed login attempt
const shouldLock = await this.rateLimitService.recordFailedAttempt(
data.email,
user.email,
ipAddress,
userAgent,
);

await this.fraudService.evaluateFailedLogin(data.email, ipAddress, userAgent);
await this.fraudService.evaluateFailedLogin(user.email, ipAddress, userAgent);

if (shouldLock) {
const lockoutDuration = 30;
Expand All @@ -306,14 +315,26 @@ export class AuthService {
`Failed to send account locked email to user ${user.id} (${this.hashEmail(user.email)}): ${err.message}`,
);
});

throw new UnauthorizedException(
`Account locked due to too many failed login attempts. Please try again in ${lockoutDuration} minutes.`,
);
}

throw new UnauthorizedException('Invalid credentials');
}
}

async login(data: LoginDto, ipAddress?: string, userAgent?: string) {
await this.preflightChecks(data, ipAddress, userAgent);

const user = await this.usersService.findByEmail(data.email);
if (!user) {
// Record failed attempt even if user doesn't exist (prevent enumeration)
await this.rateLimitService.recordFailedAttempt(data.email, ipAddress, userAgent);
throw new UnauthorizedException('Invalid credentials');
}

await this.verifyCredentials(user, data.password, ipAddress, userAgent);

if (user.twoFactorEnabled) {
const hasTotpCode = Boolean(data.totpCode?.trim());
Expand Down
Loading