Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BUGFIX] Correction de la gestion des erreurs de connexion SSO OIDC (PIX-15621) #11056

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
Open
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
44 changes: 40 additions & 4 deletions api/db/seeds/data/team-acces/build-blocked-users.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
import { config } from '../../../../src/shared/config.js';
import { DEFAULT_PASSWORD } from '../../../constants.js';

function _buildUserBeforeBeingBlocked(databaseBuilder) {
function _buildBlockedUser(databaseBuilder) {
const blockedUser = databaseBuilder.factory.buildUser.withRawPassword({
firstName: 'Goldi',
lastName: 'Locks',
email: '[email protected]',
username: 'goldi.locks0101',
username: 'goldi.locks',
rawPassword: DEFAULT_PASSWORD,
cgu: false,
});

databaseBuilder.factory.buildUserLogin({
userId: blockedUser.id,
failureCount: 49,
failureCount: config.login.blockingLimitFailureCount,
blockedAt: new Date(),
});
}

function _buildAlmostBlockedUser(databaseBuilder) {
const almostBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({
firstName: 'Silveri',
lastName: 'Locks',
email: '[email protected]',
username: 'silveri.locks',
rawPassword: DEFAULT_PASSWORD,
cgu: false,
});

databaseBuilder.factory.buildUserLogin({
userId: almostBlockedUser.id,
failureCount: config.login.blockingLimitFailureCount - 1,
});
}

function _buildAlmostTemporarilyBlockedUser(databaseBuilder) {
const temporaryBlockedUser = databaseBuilder.factory.buildUser.withRawPassword({
firstName: 'Little',
lastName: 'Bear',
email: '[email protected]',
username: 'little.bear0101',
rawPassword: DEFAULT_PASSWORD,
cgu: false,
});

databaseBuilder.factory.buildUserLogin({
userId: temporaryBlockedUser.id,
failureCount: config.login.temporaryBlockingThresholdFailureCount - 1,
});
}

export function buildBlockedUsers(databaseBuilder) {
_buildUserBeforeBeingBlocked(databaseBuilder);
_buildBlockedUser(databaseBuilder);
_buildAlmostBlockedUser(databaseBuilder);
_buildAlmostTemporarilyBlockedUser(databaseBuilder);
}
21 changes: 0 additions & 21 deletions api/db/seeds/data/team-acces/build-temporary-blocked-user.js

This file was deleted.

2 changes: 0 additions & 2 deletions api/db/seeds/data/team-acces/data-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@ import { buildPixAdminRoles } from './build-pix-admin-roles.js';
import { buildResetPasswordUsers } from './build-reset-password-users.js';
import { buildScoOrganizationLearners } from './build-sco-organization-learners.js';
import { buildScoOrganizations } from './build-sco-organizations.js';
import { buildTemporaryBlockedUsers } from './build-temporary-blocked-user.js';
import { buildUsers } from './build-users.js';

async function teamAccesDataBuilder(databaseBuilder) {
buildPixAdminRoles(databaseBuilder);
buildUsers(databaseBuilder);
buildBlockedUsers(databaseBuilder);
buildResetPasswordUsers(databaseBuilder);
buildTemporaryBlockedUsers(databaseBuilder);
buildOrganizationUsers(databaseBuilder);
buildScoOrganizations(databaseBuilder);
buildArchivedOrganizations(databaseBuilder);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
const authenticationDomainErrorMappingConfiguration = [
{
name: AuthenticationKeyExpired.name,
httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message),
httpErrorFn: (error) => new HttpErrors.UnauthorizedError(error.message, error.code),
},
{
name: DifferentExternalIdentifierError.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const tokenRoutes = [
handler: (request, h) => tokenController.createToken(request, h),
tags: ['identity-access-management', 'api', 'token'],
notes: [
"Cette route permet d'obtenir un refresh token et access token à partir d'un couple identifiant / mot de passe" +
"Cette route permet d'obtenir un access token et un refresh token à partir d'un couple identifiant / mot de passe" +
" ou un access token à partir d'un refresh token valide.",
],
},
Expand Down
4 changes: 2 additions & 2 deletions api/src/identity-access-management/domain/errors.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { DomainError } from '../../shared/domain/errors.js';

class AuthenticationKeyExpired extends DomainError {
constructor(message = 'This authentication key has expired.') {
super(message);
constructor() {
super('This authentication key has expired.', 'EXPIRED_AUTHENTICATION_KEY');
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { AuthenticationKeyExpired, DifferentExternalIdentifierError } from '../errors.js';
import { UserNotFoundError } from '../../../shared/domain/errors.js';
import {
AuthenticationKeyExpired,
DifferentExternalIdentifierError,
MissingOrInvalidCredentialsError,
PasswordNotMatching,
} from '../errors.js';

/**
* @typedef {function} findUserForOidcReconciliation
Expand All @@ -23,41 +29,49 @@ const findUserForOidcReconciliation = async function ({
authenticationMethodRepository,
userRepository,
}) {
const sessionContentAndUserInfo = await authenticationSessionService.getByKey(authenticationKey);
if (!sessionContentAndUserInfo) {
throw new AuthenticationKeyExpired();
}
try {
const sessionContentAndUserInfo = await authenticationSessionService.getByKey(authenticationKey);
if (!sessionContentAndUserInfo) {
throw new AuthenticationKeyExpired();
}

const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({
username: email,
password,
userRepository,
});

const authenticationMethods = await authenticationMethodRepository.findByUserId({ userId: foundUser.id });
const oidcAuthenticationMethod = authenticationMethods.find(
(authenticationMethod) => authenticationMethod.identityProvider === identityProvider,
);

const isSameExternalIdentifier =
oidcAuthenticationMethod?.externalIdentifier === sessionContentAndUserInfo.userInfo.externalIdentityId;
if (oidcAuthenticationMethod && !isSameExternalIdentifier) {
throw new DifferentExternalIdentifierError();
}
const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({
username: email,
password,
userRepository,
});

const authenticationMethods = await authenticationMethodRepository.findByUserId({ userId: foundUser.id });
const oidcAuthenticationMethod = authenticationMethods.find(
(authenticationMethod) => authenticationMethod.identityProvider === identityProvider,
);

sessionContentAndUserInfo.userInfo.userId = foundUser.id;
await authenticationSessionService.update(authenticationKey, sessionContentAndUserInfo);
const isSameExternalIdentifier =
oidcAuthenticationMethod?.externalIdentifier === sessionContentAndUserInfo.userInfo.externalIdentityId;
if (oidcAuthenticationMethod && !isSameExternalIdentifier) {
throw new DifferentExternalIdentifierError();
}

const fullNameFromPix = `${foundUser.firstName} ${foundUser.lastName}`;
const fullNameFromExternalIdentityProvider = `${sessionContentAndUserInfo.userInfo.firstName} ${sessionContentAndUserInfo.userInfo.lastName}`;
sessionContentAndUserInfo.userInfo.userId = foundUser.id;
await authenticationSessionService.update(authenticationKey, sessionContentAndUserInfo);

return {
fullNameFromPix,
fullNameFromExternalIdentityProvider,
email: foundUser.email,
username: foundUser.username,
authenticationMethods,
};
const fullNameFromPix = `${foundUser.firstName} ${foundUser.lastName}`;
const fullNameFromExternalIdentityProvider = `${sessionContentAndUserInfo.userInfo.firstName} ${sessionContentAndUserInfo.userInfo.lastName}`;

return {
fullNameFromPix,
fullNameFromExternalIdentityProvider,
email: foundUser.email,
username: foundUser.username,
authenticationMethods,
};
} catch (error) {
if (error instanceof UserNotFoundError || error instanceof PasswordNotMatching) {
throw new MissingOrInvalidCredentialsError();
} else {
throw error;
}
}
};

export { findUserForOidcReconciliation };
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MissingOrInvalidCredentialsError } from '../../../../../src/identity-access-management/domain/errors.js';
import { authenticationSessionService } from '../../../../../src/identity-access-management/domain/services/authentication-session.service.js';
import { usecases } from '../../../../../src/identity-access-management/domain/usecases/index.js';
import { databaseBuilder, expect } from '../../../../test-helper.js';

describe('Integration | Identity Access Management | Domain | UseCase | findUserForOidcReconciliation', function () {
context('when user exists', function () {
const email = '[email protected]';

beforeEach(async function () {
databaseBuilder.factory.buildUser.withRawPassword({ email });
await databaseBuilder.commit();
});

context('when given password is wrong', function () {
it('throws a MissingOrInvalidCredentialsError', async function () {
// given
const authenticationContent = {
sessionContent: {},
userInfo: {
email,
firstName: 'aFirstName',
lastName: 'aLastName',
externalIdentityId: 'some-user-unique-id',
},
};
const authenticationKey = await authenticationSessionService.save(authenticationContent);

const wrongPassword = 'a wrong password';

// when & then
await expect(
usecases.findUserForOidcReconciliation({
authenticationKey,
email,
password: wrongPassword,
identityProvider: 'OIDC_EXAMPLE_NET',
}),
).to.be.rejectedWith(MissingOrInvalidCredentialsError);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@ describe('Unit | Identity Access Management | Application | HttpErrorMapperConfi

context('when mapping "AuthenticationKeyExpired"', function () {
it('returns an UnauthorizedError Http Error', function () {
//given
// given
const httpErrorMapper = authenticationDomainErrorMappingConfiguration.find(
(httpErrorMapper) => httpErrorMapper.name === AuthenticationKeyExpired.name,
);

//when
// when
const error = httpErrorMapper.httpErrorFn(new AuthenticationKeyExpired());

//then
// then
expect(error).to.be.instanceOf(HttpErrors.UnauthorizedError);
expect(error.code).to.equal('EXPIRED_AUTHENTICATION_KEY');
});
});

Expand Down
69 changes: 21 additions & 48 deletions mon-pix/app/components/authentication/login-or-register-oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import isEmailValid from '../../utils/email-validator';

const ERROR_INPUT_MESSAGE_MAP = {
termsOfServiceNotSelected: 'pages.login-or-register-oidc.error.error-message',
unknownError: 'common.error',
expiredAuthenticationKey: 'pages.login-or-register-oidc.error.expired-authentication-key',
invalidEmail: 'pages.login-or-register-oidc.error.invalid-email',
accountConflict: 'pages.login-or-register-oidc.error.account-conflict',
loginUnauthorizedError: 'pages.login-or-register-oidc.error.login-unauthorized-error',
};

export default class LoginOrRegisterOidcComponent extends Component {
Expand All @@ -23,6 +19,7 @@ export default class LoginOrRegisterOidcComponent extends Component {
@service oidcIdentityProviders;
@service store;
@service url;
@service errorMessages;

@tracked isTermsOfServiceValidated = false;
@tracked loginErrorMessage = null;
Expand Down Expand Up @@ -58,6 +55,25 @@ export default class LoginOrRegisterOidcComponent extends Component {
return this.url.dataProtectionPolicyUrl;
}

@action
async login(event) {
event.preventDefault();

this.loginErrorMessage = null;

if (!this.isFormValid) return;

this.isLoginLoading = true;

try {
await this.args.onLogin({ enteredEmail: this.email, enteredPassword: this.password });
} catch (responseError) {
this.loginErrorMessage = this.errorMessages.getErrorMessage(responseError);
} finally {
this.isLoginLoading = false;
}
}

@action
async register() {
if (!this.isTermsOfServiceValidated) {
Expand All @@ -76,24 +92,7 @@ export default class LoginOrRegisterOidcComponent extends Component {
});
} catch (responseError) {
const error = get(responseError, 'errors[0]');
switch (error?.code) {
case 'INVALID_LOCALE_FORMAT':
this.registerErrorMessage = this.intl.t('pages.sign-up.errors.invalid-locale-format', {
invalidLocale: error.meta.locale,
});
return;
case 'LOCALE_NOT_SUPPORTED':
this.registerErrorMessage = this.intl.t('pages.sign-up.errors.locale-not-supported', {
localeNotSupported: error.meta.locale,
});
return;
}
if (error.status === '401') {
this.registerErrorMessage = this.intl.t(ERROR_INPUT_MESSAGE_MAP['expiredAuthenticationKey']);
} else {
this.registerErrorMessage =
this.intl.t(ERROR_INPUT_MESSAGE_MAP['unknownError']) + (error.detail ? ` (${error.detail})` : '');
}
this.registerErrorMessage = this.errorMessages.getErrorMessage(error);
} finally {
this.isRegisterLoading = false;
}
Expand Down Expand Up @@ -123,32 +122,6 @@ export default class LoginOrRegisterOidcComponent extends Component {
return isEmailValid(this.email) && !isEmpty(this.password);
}

@action
async login(event) {
event.preventDefault();

this.loginErrorMessage = null;

if (!this.isFormValid) return;

this.isLoginLoading = true;

try {
await this.args.onLogin({ enteredEmail: this.email, enteredPassword: this.password });
} catch (error) {
const status = get(error, 'errors[0].status');

const errorsMapping = {
401: this.intl.t(ERROR_INPUT_MESSAGE_MAP['expiredAuthenticationKey']),
404: this.intl.t(ERROR_INPUT_MESSAGE_MAP['loginUnauthorizedError']),
409: this.intl.t(ERROR_INPUT_MESSAGE_MAP['accountConflict']),
};
this.loginErrorMessage = errorsMapping[status] || this.intl.t(ERROR_INPUT_MESSAGE_MAP['unknownError']);
} finally {
this.isLoginLoading = false;
}
}

@action
onChange(event) {
this.isTermsOfServiceValidated = !!event.target.checked;
Expand Down
Loading
Loading