diff --git a/api/src/identity-access-management/application/http-error-mapper-configuration.js b/api/src/identity-access-management/application/http-error-mapper-configuration.js index 52f4b3787d7..21d7b10fc42 100644 --- a/api/src/identity-access-management/application/http-error-mapper-configuration.js +++ b/api/src/identity-access-management/application/http-error-mapper-configuration.js @@ -6,7 +6,6 @@ import { InvalidOrAlreadyUsedEmailError, MissingOrInvalidCredentialsError, MissingUserAccountError, - PasswordNotMatching, PasswordResetDemandNotFoundError, PixAdminLoginFromPasswordDisabledError, UserCantBeCreatedError, @@ -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), diff --git a/api/src/identity-access-management/domain/errors.js b/api/src/identity-access-management/domain/errors.js index c5778002759..a9fd1dfe5a1 100644 --- a/api/src/identity-access-management/domain/errors.js +++ b/api/src/identity-access-management/domain/errors.js @@ -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; + } } } @@ -51,8 +56,9 @@ class MissingUserAccountError extends DomainError { } class PasswordNotMatching extends DomainError { - constructor(message = 'Wrong password.') { - super(message); + constructor(meta) { + super(); + this.meta = meta; } } diff --git a/api/src/identity-access-management/domain/models/UserLogin.js b/api/src/identity-access-management/domain/models/UserLogin.js index 2211f634ce6..5a10862a1b0 100644 --- a/api/src/identity-access-management/domain/models/UserLogin.js +++ b/api/src/identity-access-management/domain/models/UserLogin.js @@ -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); } @@ -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() { diff --git a/api/src/identity-access-management/domain/services/pix-authentication-service.js b/api/src/identity-access-management/domain/services/pix-authentication-service.js index 1d6dbc3acdc..39321a62a4f 100644 --- a/api/src/identity-access-management/domain/services/pix-authentication-service.js +++ b/api/src/identity-access-management/domain/services/pix-authentication-service.js @@ -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; diff --git a/api/src/identity-access-management/domain/usecases/authenticate-for-saml.usecase.js b/api/src/identity-access-management/domain/usecases/authenticate-for-saml.usecase.js index 40c663f2341..302c36ee482 100644 --- a/api/src/identity-access-management/domain/usecases/authenticate-for-saml.usecase.js +++ b/api/src/identity-access-management/domain/usecases/authenticate-for-saml.usecase.js @@ -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; } diff --git a/api/src/identity-access-management/domain/usecases/authenticate-user.js b/api/src/identity-access-management/domain/usecases/authenticate-user.js index 711cb0e2c4a..987aaa6cd96 100644 --- a/api/src/identity-access-management/domain/usecases/authenticate-user.js +++ b/api/src/identity-access-management/domain/usecases/authenticate-user.js @@ -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; } diff --git a/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js b/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js index d863459e091..8ab005dd101 100644 --- a/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js +++ b/api/src/identity-access-management/domain/usecases/find-user-for-oidc-reconciliation.usecase.js @@ -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; } diff --git a/api/src/shared/application/error-manager.js b/api/src/shared/application/error-manager.js index 231577e3cb3..d03b3312b59 100644 --- a/api/src/shared/application/error-manager.js +++ b/api/src/shared/application/error-manager.js @@ -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); @@ -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."); diff --git a/api/src/shared/application/http-errors.js b/api/src/shared/application/http-errors.js index 6333290fe3a..a1a23e735bc 100644 --- a/api/src/shared/application/http-errors.js +++ b/api/src/shared/application/http-errors.js @@ -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; } } diff --git a/api/src/shared/application/usecases/checkIfUserIsBlocked.js b/api/src/shared/application/usecases/checkIfUserIsBlocked.js index 4f5237fa785..043d4a00a00 100644 --- a/api/src/shared/application/usecases/checkIfUserIsBlocked.js +++ b/api/src/shared/application/usecases/checkIfUserIsBlocked.js @@ -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(), + }); } }; diff --git a/api/src/shared/domain/errors.js b/api/src/shared/domain/errors.js index 09470c4c6a0..c0cf93c3614 100644 --- a/api/src/shared/domain/errors.js +++ b/api/src/shared/domain/errors.js @@ -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); } } diff --git a/api/tests/identity-access-management/integration/domain/services/pix-authentication-service.test.js b/api/tests/identity-access-management/integration/domain/services/pix-authentication-service.test.js new file mode 100644 index 00000000000..e87db2736f9 --- /dev/null +++ b/api/tests/identity-access-management/integration/domain/services/pix-authentication-service.test.js @@ -0,0 +1,221 @@ +import { PasswordNotMatching } from '../../../../../src/identity-access-management/domain/errors.js'; +import { User } from '../../../../../src/identity-access-management/domain/models/User.js'; +import { pixAuthenticationService } from '../../../../../src/identity-access-management/domain/services/pix-authentication-service.js'; +import * as userRepository from '../../../../../src/identity-access-management/infrastructure/repositories/user.repository.js'; +import { UserNotFoundError } from '../../../../../src/shared/domain/errors.js'; +import * as userLoginRepository from '../../../../../src/shared/infrastructure/repositories/user-login-repository.js'; +import { catchErr, databaseBuilder, expect, sinon } from '../../../../test-helper.js'; + +const now = new Date('2024-04-05T03:04:05Z'); +const password = 'Password123'; + +describe('Integration | Identity Access Management | Domain | Services | pix-authentication-service', function () { + describe('#getUserByUsernameAndPassword', function () { + let user; + let clock; + + beforeEach(async function () { + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + + user = databaseBuilder.factory.buildUser.withRawPassword({ + email: 'user@example.net', + username: 'user123', + rawPassword: password, + }); + await databaseBuilder.commit(); + }); + + afterEach(function () { + clock.restore(); + }); + + context('When user credentials are valid', function () { + it('returns user found and create an UserLogin entry', async function () { + // when + const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({ + username: user.username, + password: password, + userRepository, + }); + + // then + expect(foundUser).to.be.an.instanceof(User); + expect(foundUser.id).to.equal(user.id); + + const userLogin = await userLoginRepository.findByUserId(user.id); + expect(userLogin.failureCount).to.equal(0); + }); + + context('when user has some failure count', function () { + it('resets failure count', async function () { + // given + databaseBuilder.factory.buildUserLogin({ userId: user.id, failureCount: 2 }); + await databaseBuilder.commit(); + + // when + await pixAuthenticationService.getUserByUsernameAndPassword({ + username: user.username, + password: password, + userRepository, + }); + + // then + const userLogin = await userLoginRepository.findByUserId(user.id); + expect(userLogin.failureCount).to.equal(0); + }); + }); + + context('when user has failure count and was temporary blocked', function () { + it('resets failure count and remove temporary blocking', async function () { + // given + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 12, + temporaryBlockedUntil: new Date('2024-04-04T03:04:05Z'), + }); + await databaseBuilder.commit(); + + // when + await pixAuthenticationService.getUserByUsernameAndPassword({ + username: user.username, + password: password, + userRepository, + }); + + // then + const userLogin = await userLoginRepository.findByUserId(user.id); + expect(userLogin.failureCount).to.equal(0); + expect(userLogin.temporaryBlockedUntil).to.be.null; + }); + }); + }); + + context('When user credentials are not valid', function () { + context('When username does not exist', function () { + it('throws UserNotFoundError ', async function () { + // when + const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ + username: 'nonexistentuser', + password, + userRepository, + }); + + // then + expect(error).to.be.an.instanceof(UserNotFoundError); + }); + }); + + context('When password does not match', function () { + context('When user failed to login for the first time with username', function () { + it('throws PasswordNotMatching error and increment user failure count', async function () { + // when + const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ + username: user.username, + password: 'WrongPassword', + userRepository, + }); + + // then + const userLogin = await userLoginRepository.findByUserId(user.id); + expect(userLogin.failureCount).to.equal(1); + + expect(error).to.be.an.instanceof(PasswordNotMatching); + expect(error.meta.isLoginFailureWithUsername).to.be.true; + }); + }); + + context('When user failed to login for the first time with email', function () { + it('throws PasswordNotMatching error with isLoginFailureWithUsername false', async function () { + // when + const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ + username: user.email, + password: 'WrongPassword', + userRepository, + }); + + // then + expect(error).to.be.an.instanceof(PasswordNotMatching); + expect(error.meta.isLoginFailureWithUsername).to.be.false; + }); + }); + + context('When user failed to login with username multiple times', function () { + it('throws PasswordNotMatching error and block temporarily the user', async function () { + // given + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 9, + temporaryBlockedUntil: null, + }); + await databaseBuilder.commit(); + + // when + const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ + username: user.username, + password: 'WrongPassword', + userRepository, + }); + + // then + const userLogin = await userLoginRepository.findByUserId(user.id); + expect(userLogin.failureCount).to.equal(10); + expect(userLogin.temporaryBlockedUntil).not.to.be.null; + + expect(error).to.be.an.instanceof(PasswordNotMatching); + expect(error.meta.remainingAttempts).to.be.null; + expect(error.meta.isLoginFailureWithUsername).to.be.true; + }); + + context('When less than 10 attempts remaining before blocking', function () { + it('throws PasswordNotMatching with remaining attempts', async function () { + // given + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 21, + temporaryBlockedUntil: null, + }); + await databaseBuilder.commit(); + + // when + const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ + username: user.username, + password: 'WrongPassword', + userRepository, + }); + + // then + expect(error).to.be.an.instanceof(PasswordNotMatching); + expect(error.meta.remainingAttempts).to.be.equal(9); + }); + }); + }); + + context('When user failure count reaches the blocking limit', function () { + it('throws PasswordNotMatching error and block the user', async function () { + // given + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 30, + temporaryBlockedUntil: null, + }); + await databaseBuilder.commit(); + + // when + const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ + username: user.username, + password: 'WrongPassword', + userRepository, + }); + + // then + const userLogin = await userLoginRepository.findByUserId(user.id); + expect(userLogin.blockedUntil).not.to.be.null; + + expect(error).to.be.an.instanceof(PasswordNotMatching); + expect(error.meta.remainingAttempts).to.equal(0); + }); + }); + }); + }); + }); +}); diff --git a/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js b/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js index 1b482e2af90..562a4b9f431 100644 --- a/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js +++ b/api/tests/identity-access-management/unit/application/http-error-mapper-configuration_test.js @@ -4,7 +4,6 @@ import { DifferentExternalIdentifierError, MissingOrInvalidCredentialsError, MissingUserAccountError, - PasswordNotMatching, PasswordResetDemandNotFoundError, UserCantBeCreatedError, UserShouldChangePasswordError, @@ -84,23 +83,6 @@ describe('Unit | Identity Access Management | Application | HttpErrorMapperConfi }); }); - context('when mapping "PasswordNotMatching"', function () { - it('returns an UnauthorizedError Http Error', function () { - //given - const httpErrorMapper = authenticationDomainErrorMappingConfiguration.find( - (httpErrorMapper) => httpErrorMapper.name === PasswordNotMatching.name, - ); - const message = 'Test message error'; - - //when - const error = httpErrorMapper.httpErrorFn(new PasswordNotMatching(message)); - - //then - expect(error).to.be.instanceOf(HttpErrors.UnauthorizedError); - expect(error.message).to.equal(message); - }); - }); - context('when mapping "PasswordResetDemandNotFoundError"', function () { it('returns a NotFoundError Http Error', function () { //given diff --git a/api/tests/identity-access-management/unit/domain/services/pix-authentication-service.test.js b/api/tests/identity-access-management/unit/domain/services/pix-authentication-service.test.js deleted file mode 100644 index 8eb39740791..00000000000 --- a/api/tests/identity-access-management/unit/domain/services/pix-authentication-service.test.js +++ /dev/null @@ -1,257 +0,0 @@ -import { PasswordNotMatching } from '../../../../../src/identity-access-management/domain/errors.js'; -import { User } from '../../../../../src/identity-access-management/domain/models/User.js'; -import { UserLogin } from '../../../../../src/identity-access-management/domain/models/UserLogin.js'; -import { pixAuthenticationService } from '../../../../../src/identity-access-management/domain/services/pix-authentication-service.js'; -import { UserNotFoundError } from '../../../../../src/shared/domain/errors.js'; -import { catchErr, domainBuilder, expect, sinon } from '../../../../test-helper.js'; - -describe('Unit | Identity Access Management | Domain | Services | pix-authentication-service', function () { - describe('#getUserByUsernameAndPassword', function () { - const username = 'user@example.net'; - const password = 'Password123'; - - let user; - let userLogin; - let authenticationMethod; - let userRepository; - let userLoginRepository; - let cryptoService; - - beforeEach(function () { - user = domainBuilder.buildUser({ username }); - authenticationMethod = domainBuilder.buildAuthenticationMethod.withPixAsIdentityProviderAndRawPassword({ - userId: user.id, - rawPassword: password, - }); - user.authenticationMethods = [authenticationMethod]; - userLogin = new UserLogin({ userId: user.id }); - - userRepository = { - getByUsernameOrEmailWithRolesAndPassword: sinon.stub(), - }; - userLoginRepository = { - findByUserId: sinon.stub(), - create: sinon.stub(), - update: sinon.stub(), - }; - cryptoService = { - checkPassword: sinon.stub(), - }; - }); - - context('When user credentials are valid', function () { - beforeEach(function () { - userRepository.getByUsernameOrEmailWithRolesAndPassword.resolves(user); - userLoginRepository.findByUserId.withArgs(user.id).resolves(userLogin); - cryptoService.checkPassword.resolves(); - }); - - it('should call the user repository', async function () { - // when - await pixAuthenticationService.getUserByUsernameAndPassword({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(userRepository.getByUsernameOrEmailWithRolesAndPassword).to.has.been.calledWithExactly(username); - }); - - it('should call the cryptoService check function', async function () { - // given - const expectedPasswordHash = authenticationMethod.authenticationComplement.password; - - // when - await pixAuthenticationService.getUserByUsernameAndPassword({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(cryptoService.checkPassword).to.has.been.calledWithExactly({ - password, - passwordHash: expectedPasswordHash, - }); - }); - - it('should return user found', async function () { - // when - const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(foundUser).to.be.an.instanceof(User); - expect(foundUser).to.equal(user); - }); - - context('when user is not temporary blocked', function () { - it('should not reset password failure count', async function () { - // given - const userLogin = { hasFailedAtLeastOnce: sinon.stub().returns(false) }; - userLoginRepository.findByUserId.withArgs(user.id).resolves(userLogin); - - // when - await pixAuthenticationService.getUserByUsernameAndPassword({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(userLoginRepository.update).to.not.have.been.called; - }); - }); - - context('when user is temporary blocked', function () { - it('should reset password failure count', async function () { - // given - const user = domainBuilder.buildUser({ username }); - const resetUserTemporaryBlockingStub = sinon.stub(); - const userLogin = { - hasFailedAtLeastOnce: sinon.stub().returns(true), - resetUserTemporaryBlocking: resetUserTemporaryBlockingStub, - }; - userLoginRepository.findByUserId.withArgs(user.id).resolves(userLogin); - - // when - await pixAuthenticationService.getUserByUsernameAndPassword({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(resetUserTemporaryBlockingStub).to.have.been.calledOnce; - expect(userLoginRepository.update).to.have.been.calledWithExactly(userLogin); - }); - }); - }); - - context('When user credentials are not valid', function () { - it('should throw UserNotFoundError when username does not exist', async function () { - // given - userRepository.getByUsernameOrEmailWithRolesAndPassword.rejects(new UserNotFoundError()); - - // when - const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(error).to.be.an.instanceof(UserNotFoundError); - }); - - context('When username exists and password does not match', function () { - context('When user failed to login for the first time', function () { - it('throws passwordNotMatching error, increment user failure count and create an user logins', async function () { - // given - userRepository.getByUsernameOrEmailWithRolesAndPassword.resolves(user); - cryptoService.checkPassword.rejects(new PasswordNotMatching()); - const userLoginCreated = { - incrementFailureCount: sinon.stub(), - shouldMarkUserAsTemporarilyBlocked: sinon.stub().returns(false), - markUserAsTemporarilyBlocked: sinon.stub(), - shouldMarkUserAsBlocked: sinon.stub().returns(false), - markUserAsBlocked: sinon.stub(), - }; - userLoginRepository.findByUserId.withArgs(user.id).resolves(null); - userLoginRepository.create.resolves(userLoginCreated); - - // when - const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(userLoginRepository.create).to.have.been.calledWithExactly({ userId: user.id }); - expect(userLoginCreated.incrementFailureCount).to.have.been.calledOnce; - expect(userLoginCreated.markUserAsTemporarilyBlocked).to.not.have.been.called; - expect(userLoginCreated.markUserAsBlocked).to.not.have.been.called; - expect(userLoginRepository.update).to.have.been.calledWithExactly(userLoginCreated); - expect(error).to.be.an.instanceof(PasswordNotMatching); - }); - }); - - context('When user failed to login multiple times', function () { - it('throws passwordNotMatching error, block temporarily the user and update the user logins', async function () { - // given - userRepository.getByUsernameOrEmailWithRolesAndPassword.resolves(user); - cryptoService.checkPassword.rejects(new PasswordNotMatching()); - const userLogin = { - incrementFailureCount: sinon.stub(), - shouldMarkUserAsTemporarilyBlocked: sinon.stub().returns(true), - markUserAsTemporarilyBlocked: sinon.stub(), - shouldMarkUserAsBlocked: sinon.stub().returns(false), - markUserAsBlocked: sinon.stub(), - }; - userLoginRepository.findByUserId.withArgs(user.id).resolves(userLogin); - - // when - const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(userLoginRepository.create).to.not.have.been.called; - expect(userLogin.incrementFailureCount).to.have.been.calledOnce; - expect(userLogin.markUserAsTemporarilyBlocked).to.have.been.calledOnce; - expect(userLogin.markUserAsBlocked).to.not.have.been.called; - expect(userLoginRepository.update).to.have.been.calledWithExactly(userLogin); - expect(error).to.be.an.instanceof(PasswordNotMatching); - }); - }); - - context('When user failure count reaches limit', function () { - it('throws passwordNotMatching error, block the user and update the user logins', async function () { - // given - userRepository.getByUsernameOrEmailWithRolesAndPassword.resolves(user); - cryptoService.checkPassword.rejects(new PasswordNotMatching()); - const userLogin = { - incrementFailureCount: sinon.stub(), - shouldMarkUserAsTemporarilyBlocked: sinon.stub().returns(false), - markUserAsTemporarilyBlocked: sinon.stub(), - shouldMarkUserAsBlocked: sinon.stub().returns(true), - markUserAsBlocked: sinon.stub(), - }; - userLoginRepository.findByUserId.withArgs(user.id).resolves(userLogin); - - // when - const error = await catchErr(pixAuthenticationService.getUserByUsernameAndPassword)({ - username, - password, - userRepository, - dependencies: { userLoginRepository, cryptoService }, - }); - - // then - expect(userLoginRepository.create).to.not.have.been.called; - expect(error).to.be.an.instanceof(PasswordNotMatching); - expect(userLogin.incrementFailureCount).to.have.been.calledOnce; - expect(userLogin.markUserAsBlocked).to.have.been.calledOnce; - expect(userLogin.markUserAsTemporarilyBlocked).to.not.have.been.called; - expect(userLoginRepository.update).to.have.been.calledWithExactly(userLogin); - }); - }); - }); - }); - }); -}); diff --git a/api/tests/shared/integration/domain/services/.gitkeep b/api/tests/shared/integration/domain/services/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/tests/shared/integration/domain/usecases/.gitkeep b/api/tests/shared/integration/domain/usecases/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/tests/shared/integration/domain/usecases/check-if-user-is-blocked.test.js b/api/tests/shared/integration/domain/usecases/check-if-user-is-blocked.test.js new file mode 100644 index 00000000000..e29185bc4fd --- /dev/null +++ b/api/tests/shared/integration/domain/usecases/check-if-user-is-blocked.test.js @@ -0,0 +1,118 @@ +import { execute } from '../../../../../src/shared/application/usecases/checkIfUserIsBlocked.js'; +import { UserIsBlocked, UserIsTemporaryBlocked } from '../../../../../src/shared/domain/errors.js'; +import { catchErr, databaseBuilder, expect, sinon } from '../../../../test-helper.js'; + +describe('Integration | Shared | Domain | UseCase | check-if-user-is-blocked', function () { + let clock; + const now = new Date('2024-04-05T03:04:05Z'); + + beforeEach(function () { + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(function () { + clock.restore(); + }); + + context('when the user is not blocked', function () { + it('resolves without throwing an error', async function () { + // given + const user = databaseBuilder.factory.buildUser(); + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 0, + blockedAt: null, + temporaryBlockedUntil: null, + }); + await databaseBuilder.commit(); + + // when + const result = await execute(user.email); + + // then + expect(result).to.be.undefined; + }); + }); + + context('when the user is blocked', function () { + context('when user is logging in with email', function () { + it('throws UserIsBlocked error', async function () { + // given + const user = databaseBuilder.factory.buildUser(); + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 30, + blockedAt: new Date('2024-04-05T03:04:05Z'), + temporaryBlockedUntil: null, + }); + await databaseBuilder.commit(); + + // when / then + const error = await catchErr(execute)(user.email); + expect(error).to.be.instanceOf(UserIsBlocked); + expect(error.meta.isLoginFailureWithUsername).to.be.false; + }); + }); + + context('when user is logging in with username', function () { + it('throws UserIsBlocked error', async function () { + // given + const user = databaseBuilder.factory.buildUser({ username: 'testuser' }); + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 30, + blockedAt: new Date('2024-04-05T03:04:05Z'), + temporaryBlockedUntil: null, + }); + await databaseBuilder.commit(); + + // when / then + const error = await catchErr(execute)(user.username); + expect(error).to.be.instanceOf(UserIsBlocked); + expect(error.meta.isLoginFailureWithUsername).to.be.true; + }); + }); + }); + + context('when the user is temporary blocked', function () { + context('when user is logging in with email', function () { + it('throws UserIsTemporaryBlocked error', async function () { + // given + const user = databaseBuilder.factory.buildUser(); + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 10, + blockedAt: null, + temporaryBlockedUntil: new Date('2024-04-05T05:04:05Z'), + }); + await databaseBuilder.commit(); + + // when / then + const error = await catchErr(execute)(user.email); + expect(error).to.be.instanceOf(UserIsTemporaryBlocked); + expect(error.meta.isLoginFailureWithUsername).to.be.false; + expect(error.meta.blockingDurationMs).to.be.equal(120000); // 2 minutes in milliseconds + }); + }); + + context('when user is logging in with username', function () { + it('throws UserIsTemporaryBlocked error', async function () { + // given + const user = databaseBuilder.factory.buildUser({ username: 'testuser' }); + databaseBuilder.factory.buildUserLogin({ + userId: user.id, + failureCount: 10, + blockedAt: null, + temporaryBlockedUntil: new Date('2024-04-05T05:04:05Z'), + }); + await databaseBuilder.commit(); + + // when / then + const error = await catchErr(execute)(user.username); + expect(error).to.be.instanceOf(UserIsTemporaryBlocked); + expect(error.meta.isLoginFailureWithUsername).to.be.true; + expect(error.meta.blockingDurationMs).to.be.equal(120000); // 2 minutes in milliseconds + }); + }); + }); +}); diff --git a/api/tests/unit/domain/models/UserLogin_test.js b/api/tests/unit/domain/models/UserLogin_test.js index 18cb3368814..b60b8e10db5 100644 --- a/api/tests/unit/domain/models/UserLogin_test.js +++ b/api/tests/unit/domain/models/UserLogin_test.js @@ -14,6 +14,56 @@ describe('Unit | Domain | Models | UserLogin', function () { clock.restore(); }); + describe('#remainingAttempts', function () { + it('returns remaining attempts before blocking', function () { + // given + const { blockingLimitFailureCount } = config.login; + const userLogin = new UserLogin({ userId: 666, failureCount: 0 }); + + // when + const remainingAttempts = userLogin.remainingAttempts; + + // then + expect(remainingAttempts).to.equal(blockingLimitFailureCount + 1); + }); + + it('returns 0 when failure count is greater than blocking limit', function () { + // given + const { blockingLimitFailureCount } = config.login; + const userLogin = new UserLogin({ userId: 666, failureCount: blockingLimitFailureCount + 1 }); + + // when + const remainingAttempts = userLogin.remainingAttempts; + + // then + expect(remainingAttempts).to.equal(0); + }); + }); + + describe('#shouldWarnRemainingAttempts', function () { + it('returns true to warn remaining attempts is reaching final block', function () { + // given + const userLogin = new UserLogin({ userId: 666, failureCount: 25 }); + + // when + const shouldWarnRemainingAttempts = userLogin.shouldWarnRemainingAttempts; + + // then + expect(shouldWarnRemainingAttempts).to.equal(true); + }); + + it('returns false when remaining attempts is not reaching final block yet', function () { + // given + const userLogin = new UserLogin({ userId: 666, failureCount: 15 }); + + // when + const shouldWarnRemainingAttempts = userLogin.shouldWarnRemainingAttempts; + + // then + expect(shouldWarnRemainingAttempts).to.equal(false); + }); + }); + describe('#incrementFailureCount', function () { it('increments failure count', function () { // given @@ -27,6 +77,32 @@ describe('Unit | Domain | Models | UserLogin', function () { }); }); + describe('#computeBlockingDurationMs', function () { + it('returns the blocking duration in milliseconds based on 10 failures', function () { + // given + const userLogin = new UserLogin({ userId: 666, failureCount: 10 }); + + // when + const blockingDuration = userLogin.computeBlockingDurationMs(); + + // then + const expectedDurationMs = 120000; // 2 minutes in milliseconds + expect(blockingDuration).to.equal(expectedDurationMs); + }); + + it('returns the blocking duration in milliseconds based on 20 failures', function () { + // given + const userLogin = new UserLogin({ userId: 666, failureCount: 20 }); + + // when + const blockingDuration = userLogin.computeBlockingDurationMs(); + + // then + const expectedDurationMs = 240000; // 4 minutes in milliseconds + expect(blockingDuration).to.equal(expectedDurationMs); + }); + }); + describe('#resetUserTemporaryBlocking', function () { it('resets failure count and reset temporary blocked until', function () { // given diff --git a/mon-pix/app/components/authentication/login-form.gjs b/mon-pix/app/components/authentication/login-form.gjs index a4fc59250c2..ce778c4c46a 100644 --- a/mon-pix/app/components/authentication/login-form.gjs +++ b/mon-pix/app/components/authentication/login-form.gjs @@ -10,17 +10,8 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { t } from 'ember-intl'; import get from 'lodash/get'; -import ENV from 'mon-pix/config/environment'; import { FormValidation } from 'mon-pix/utils/form-validation'; -const HTTP_ERROR_MESSAGES = { - 400: { key: ENV.APP.API_ERROR_MESSAGES.BAD_REQUEST.I18N_KEY }, - 401: { key: ENV.APP.API_ERROR_MESSAGES.LOGIN_UNAUTHORIZED.I18N_KEY }, - 422: { key: ENV.APP.API_ERROR_MESSAGES.BAD_REQUEST.I18N_KEY }, - 504: { key: ENV.APP.API_ERROR_MESSAGES.GATEWAY_TIMEOUT.I18N_KEY }, - default: { key: 'common.api-error-messages.login-unexpected-error', values: { htmlSafe: true } }, -}; - const VALIDATION_ERRORS = { login: 'components.authentication.login-form.fields.login.error', password: 'components.authentication.login-form.fields.password.error', @@ -32,6 +23,7 @@ export default class LoginForm extends Component { @service storage; @service store; @service router; + @service errorMessages; @tracked login = null; @tracked password = null; @@ -84,59 +76,18 @@ export default class LoginForm extends Component { async _handleApiError(responseError) { const errors = get(responseError, 'responseJSON.errors'); - const error = Array.isArray(errors) && errors.length > 0 && errors[0]; - - switch (error?.code) { - case 'INVALID_LOCALE_FORMAT': - this.globalError = { - key: 'pages.sign-up.errors.invalid-locale-format', - values: { invalidLocale: error.meta.locale }, - }; - break; - case 'LOCALE_NOT_SUPPORTED': - this.globalError = { - key: 'pages.sign-up.errors.locale-not-supported', - values: { localeNotSupported: error.meta.locale }, - }; - break; - case 'SHOULD_CHANGE_PASSWORD': { - const passwordResetToken = error.meta; - await this._updateExpiredPassword(passwordResetToken); - break; - } - case 'USER_IS_TEMPORARY_BLOCKED': - this.globalError = { - key: ENV.APP.API_ERROR_MESSAGES.USER_IS_TEMPORARY_BLOCKED.I18N_KEY, - values: { - url: '/mot-de-passe-oublie', - htmlSafe: true, - }, - }; - break; - case 'USER_IS_BLOCKED': - this.globalError = { - key: ENV.APP.API_ERROR_MESSAGES.USER_IS_BLOCKED.I18N_KEY, - values: { - url: 'https://support.pix.org/support/tickets/new', - htmlSafe: true, - }, - }; - break; - case 'MISSING_OR_INVALID_CREDENTIALS': - this.password = null; - this.globalError = { - key: ENV.APP.API_ERROR_MESSAGES.MISSING_OR_INVALID_CREDENTIALS.I18N_KEY, - }; - break; - default: { - const properties = HTTP_ERROR_MESSAGES[responseError.status] || HTTP_ERROR_MESSAGES['default']; - if (!HTTP_ERROR_MESSAGES[responseError.status]) { - properties.values.supportHomeUrl = this.url.supportHomeUrl; - } - this.globalError = properties; - return; - } + const error = Array.isArray(errors) && errors.length > 0 ? errors[0] : null; + + if (error?.code === 'SHOULD_CHANGE_PASSWORD') { + const passwordResetToken = error.meta; + return this._updateExpiredPassword(passwordResetToken); } + + if (['MISSING_OR_INVALID_CREDENTIALS', 'USER_IS_TEMPORARY_BLOCKED'].includes(error?.code)) { + this.password = null; + } + + this.globalError = this.errorMessages.getAuthenticationErrorMessage(error); } async _updateExpiredPassword(passwordResetToken) { @@ -148,7 +99,7 @@ export default class LoginForm extends Component {
{{#if this.globalError}} - {{t this.globalError.key this.globalError.values}} + {{this.globalError}} {{/if}} diff --git a/mon-pix/app/components/authentication/login-or-register-oidc.gjs b/mon-pix/app/components/authentication/login-or-register-oidc.gjs index 5b6aa44b92b..dd0486bdc24 100644 --- a/mon-pix/app/components/authentication/login-or-register-oidc.gjs +++ b/mon-pix/app/components/authentication/login-or-register-oidc.gjs @@ -208,7 +208,14 @@ export default class LoginOrRegisterOidcComponent extends Component { try { await this.args.onLogin({ enteredEmail: this.email, enteredPassword: this.password }); } catch (responseError) { - this.loginErrorMessage = this.errorMessages.getErrorMessage(responseError); + const errors = get(responseError, 'errors'); + const error = Array.isArray(errors) && errors.length > 0 ? errors[0] : null; + + if (['MISSING_OR_INVALID_CREDENTIALS', 'USER_IS_TEMPORARY_BLOCKED'].includes(error?.code)) { + this.password = null; + } + + this.loginErrorMessage = this.errorMessages.getAuthenticationErrorMessage(responseError); } finally { this.isLoginLoading = false; } @@ -232,7 +239,7 @@ export default class LoginOrRegisterOidcComponent extends Component { }); } catch (responseError) { const error = get(responseError, 'errors[0]'); - this.registerErrorMessage = this.errorMessages.getErrorMessage(error); + this.registerErrorMessage = this.errorMessages.getAuthenticationErrorMessage(error); } finally { this.isRegisterLoading = false; } diff --git a/mon-pix/app/components/authentication/oidc-reconciliation.gjs b/mon-pix/app/components/authentication/oidc-reconciliation.gjs index 211b61c4dae..ca5f7901b96 100644 --- a/mon-pix/app/components/authentication/oidc-reconciliation.gjs +++ b/mon-pix/app/components/authentication/oidc-reconciliation.gjs @@ -132,7 +132,7 @@ export default class OidcReconciliationComponent extends Component { hostSlug: 'user/reconcile', }); } catch (responseError) { - this.reconcileErrorMessage = this.errorMessages.getErrorMessage(responseError); + this.reconcileErrorMessage = this.errorMessages.getAuthenticationErrorMessage(responseError); } finally { this.isLoading = false; } diff --git a/mon-pix/app/components/authentication/signup-form/index.gjs b/mon-pix/app/components/authentication/signup-form/index.gjs index 273394b7550..b74dac2c51b 100644 --- a/mon-pix/app/components/authentication/signup-form/index.gjs +++ b/mon-pix/app/components/authentication/signup-form/index.gjs @@ -8,7 +8,6 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { t } from 'ember-intl'; import get from 'lodash/get'; -import ENV from 'mon-pix/config/environment'; import isEmailValid from 'mon-pix/utils/email-validator.js'; import { FormValidation } from 'mon-pix/utils/form-validation'; import isPasswordValid, { PASSWORD_RULES } from 'mon-pix/utils/password-validator.js'; @@ -16,12 +15,6 @@ import isPasswordValid, { PASSWORD_RULES } from 'mon-pix/utils/password-validato import NewPasswordInput from '../new-password-input'; import CguCheckbox from './cgu-checkbox'; -const HTTP_ERROR_MESSAGES = { - 400: { key: ENV.APP.API_ERROR_MESSAGES.BAD_REQUEST.I18N_KEY }, - 504: { key: ENV.APP.API_ERROR_MESSAGES.GATEWAY_TIMEOUT.I18N_KEY }, - default: { key: 'common.api-error-messages.login-unexpected-error', values: { htmlSafe: true } }, -}; - const VALIDATION_ERRORS = { firstName: 'components.authentication.signup-form.fields.firstname.error', lastName: 'components.authentication.signup-form.fields.lastname.error', @@ -38,6 +31,7 @@ export default class SignupForm extends Component { @service session; @service intl; @service url; + @service errorMessages; @tracked isLoading = false; @tracked globalError = null; @@ -115,35 +109,14 @@ export default class SignupForm extends Component { return this.validation.setErrorsFromApi(errors); } - switch (error?.code) { - case 'INVALID_LOCALE_FORMAT': - this.globalError = { - key: 'components.authentication.signup-form.errors.invalid-locale-format', - values: { invalidLocale: error.meta.locale }, - }; - return; - case 'LOCALE_NOT_SUPPORTED': - this.globalError = { - key: 'components.authentication.signup-form.errors.locale-not-supported', - values: { localeNotSupported: error.meta.locale }, - }; - return; - default: { - const properties = HTTP_ERROR_MESSAGES[statusCode] || HTTP_ERROR_MESSAGES['default']; - if (!HTTP_ERROR_MESSAGES[statusCode]) { - properties.values.supportHomeUrl = this.url.supportHomeUrl; - } - this.globalError = properties; - return; - } - } + this.globalError = this.errorMessages.getAuthenticationErrorMessage(error); }