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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
InvalidOrAlreadyUsedEmailError,
MissingOrInvalidCredentialsError,
MissingUserAccountError,
PasswordNotMatching,
PasswordResetDemandNotFoundError,
PixAdminLoginFromPasswordDisabledError,
UserCantBeCreatedError,
Expand All @@ -28,16 +27,12 @@ const authenticationDomainErrorMappingConfiguration = [
},
{
name: MissingOrInvalidCredentialsError.name,
httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message, error.code),
httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message, error.code, error.meta),
},
{
name: MissingUserAccountError.name,
httpErrorFn: (error) => new HttpErrors.BadRequestError(error.message),
},
{
name: PasswordNotMatching.name,
httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message),
},
{
name: PasswordResetDemandNotFoundError.name,
httpErrorFn: (error) => new HttpErrors.NotFoundError(error.message),
Expand Down
12 changes: 9 additions & 3 deletions api/src/identity-access-management/domain/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ class OrganizationLearnerIdentityNotFoundError extends DomainError {
}

class MissingOrInvalidCredentialsError extends DomainError {
constructor() {
constructor(meta = {}) {
super('Missing or invalid credentials', 'MISSING_OR_INVALID_CREDENTIALS');
this.meta = {};
this.meta.isLoginFailureWithUsername = meta?.isLoginFailureWithUsername ?? false;
if (meta?.remainingAttempts) {
this.meta = meta.remainingAttempts;
}
}
}

Expand All @@ -51,8 +56,9 @@ class MissingUserAccountError extends DomainError {
}

class PasswordNotMatching extends DomainError {
constructor(message = 'Wrong password.') {
super(message);
constructor(meta) {
super();
this.meta = meta;
}
}

Expand Down
20 changes: 18 additions & 2 deletions api/src/identity-access-management/domain/models/UserLogin.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,27 @@ class UserLogin {
this.lastLoggedAt = lastLoggedAt;
}

get remainingAttempts() {
if (this.failureCount > config.login.blockingLimitFailureCount) {
return 0;
}
return config.login.blockingLimitFailureCount - this.failureCount + 1;
}

get shouldWarnRemainingAttempts() {
const warnLimit = config.login.temporaryBlockingThresholdFailureCount;
return this.remainingAttempts >= 0 && this.remainingAttempts <= warnLimit;
}

incrementFailureCount() {
this.failureCount++;
}

computeBlockingDurationMs() {
const commonRatio = Math.pow(2, this.failureCount / config.login.temporaryBlockingThresholdFailureCount - 1);
return config.login.temporaryBlockingBaseTimeMs * commonRatio;
}

hasFailedAtLeastOnce() {
return this.failureCount > 0 || Boolean(this.temporaryBlockedUntil);
}
Expand All @@ -47,8 +64,7 @@ class UserLogin {
}

markUserAsTemporarilyBlocked() {
const commonRatio = Math.pow(2, this.failureCount / config.login.temporaryBlockingThresholdFailureCount - 1);
this.temporaryBlockedUntil = new Date(Date.now() + config.login.temporaryBlockingBaseTimeMs * commonRatio);
this.temporaryBlockedUntil = new Date(Date.now() + this.computeBlockingDurationMs());
}

isUserMarkedAsTemporaryBlocked() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ async function getUserByUsernameAndPassword({
}

await dependencies.userLoginRepository.update(userLogin);

throw new PasswordNotMatching({
remainingAttempts: userLogin.shouldWarnRemainingAttempts ? userLogin.remainingAttempts : null,
isLoginFailureWithUsername: username === foundUser.username,
});
}

throw error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ async function authenticateForSaml({

return token;
} catch (error) {
if (error instanceof UserNotFoundError || error instanceof PasswordNotMatching) {
if (error instanceof UserNotFoundError) {
throw new MissingOrInvalidCredentialsError();
} else if (error instanceof PasswordNotMatching) {
throw new MissingOrInvalidCredentialsError(error.meta);
} else {
throw error;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ const authenticateUser = async function ({

return { accessToken, refreshToken: refreshToken.value, expirationDelaySeconds };
} catch (error) {
if (error instanceof UserNotFoundError || error instanceof PasswordNotMatching) {
if (error instanceof UserNotFoundError) {
throw new MissingOrInvalidCredentialsError();
} else if (error instanceof PasswordNotMatching) {
throw new MissingOrInvalidCredentialsError(error.meta);
} else {
throw error;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ const findUserForOidcReconciliation = async function ({
authenticationMethods,
};
} catch (error) {
if (error instanceof UserNotFoundError || error instanceof PasswordNotMatching) {
if (error instanceof UserNotFoundError) {
throw new MissingOrInvalidCredentialsError();
} else if (error instanceof PasswordNotMatching) {
throw new MissingOrInvalidCredentialsError(error.meta);
} else {
throw error;
}
Expand Down
4 changes: 2 additions & 2 deletions api/src/shared/application/error-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ function _mapToHttpError(error) {
return new HttpErrors.ForbiddenError('Utilisateur non autorisé à accéder à la ressource');
}
if (error instanceof SharedDomainErrors.UserIsTemporaryBlocked) {
return new HttpErrors.ForbiddenError(error.message, error.code);
return new HttpErrors.ForbiddenError(error.message, error.code, error.meta);
}
if (error instanceof SharedDomainErrors.UserHasAlreadyLeftSCO) {
return new HttpErrors.ForbiddenError(error.message);
Expand All @@ -124,7 +124,7 @@ function _mapToHttpError(error) {
return new HttpErrors.NotFoundError(error.message);
}
if (error instanceof SharedDomainErrors.UserIsBlocked) {
return new HttpErrors.ForbiddenError(error.message, error.code);
return new HttpErrors.ForbiddenError(error.message, error.code, error.meta);
}
if (error instanceof SharedDomainErrors.UserAlreadyLinkedToCandidateInSessionError) {
return new HttpErrors.ForbiddenError("L'utilisateur est déjà lié à un candidat dans cette session.");
Expand Down
3 changes: 2 additions & 1 deletion api/src/shared/application/http-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,12 @@ class PasswordShouldChangeError extends BaseHttpError {
}

class ForbiddenError extends BaseHttpError {
constructor(message, code) {
constructor(message, code, meta) {
super(message);
this.title = 'Forbidden';
this.status = 403;
this.code = code;
this.meta = meta;
}
}

Expand Down
24 changes: 18 additions & 6 deletions api/src/shared/application/usecases/checkIfUserIsBlocked.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import * as userRepository from '../../../identity-access-management/infrastructure/repositories/user.repository.js';
import { UserIsBlocked, UserIsTemporaryBlocked } from '../../domain/errors.js';
import * as userLoginRepository from '../../infrastructure/repositories/user-login-repository.js';

const execute = async function (username) {
const foundUserLogin = await userLoginRepository.findByUsername(username);
if (foundUserLogin?.isUserMarkedAsBlocked()) {
throw new UserIsBlocked();
const execute = async function (emailOrUsername) {
const foundUserLogin = await userLoginRepository.findByUsername(emailOrUsername);

if (!foundUserLogin) return;

const foundUser = await userRepository.get(foundUserLogin.userId);

const isLoginFailureWithUsername = emailOrUsername === foundUser.username;

if (foundUserLogin.isUserMarkedAsBlocked()) {
throw new UserIsBlocked({ isLoginFailureWithUsername });
}
if (foundUserLogin?.isUserMarkedAsTemporaryBlocked()) {
throw new UserIsTemporaryBlocked();

if (foundUserLogin.isUserMarkedAsTemporaryBlocked()) {
throw new UserIsTemporaryBlocked({
isLoginFailureWithUsername,
blockingDurationMs: foundUserLogin.computeBlockingDurationMs(),
});
}
};

Expand Down
8 changes: 4 additions & 4 deletions api/src/shared/domain/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -517,14 +517,14 @@ class UserHasAlreadyLeftSCO extends DomainError {
}

class UserIsTemporaryBlocked extends DomainError {
constructor(message = 'User has been temporary blocked.', code = 'USER_IS_TEMPORARY_BLOCKED') {
super(message, code);
constructor(meta) {
super('User has been temporary blocked.', 'USER_IS_TEMPORARY_BLOCKED', meta);
}
}

class UserIsBlocked extends DomainError {
constructor(message = 'User has been blocked.', code = 'USER_IS_BLOCKED') {
super(message, code);
constructor(meta) {
super('User has been blocked.', 'USER_IS_BLOCKED', meta);
}
}

Expand Down
Loading