diff --git a/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js b/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js index c81c59a8ea7..fc3652de4fb 100644 --- a/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js +++ b/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js @@ -1,7 +1,10 @@ import dayjs from 'dayjs'; import * as studentInformationForAccountRecoverySerializer from '../../../src/identity-access-management/infrastructure/serializers/jsonapi/student-information-for-account-recovery-serializer.js'; -import { getForwardedOrigin } from '../../../src/identity-access-management/infrastructure/utils/network.js'; +import { + getForwardedOrigin, + RequestedApplication, +} from '../../../src/identity-access-management/infrastructure/utils/network.js'; import * as scoOrganizationLearnerSerializer from '../../../src/prescription/learner-management/infrastructure/serializers/jsonapi/sco-organization-learner-serializer.js'; import { DomainTransaction } from '../../../src/shared/domain/DomainTransaction.js'; import * as requestResponseUtils from '../../../src/shared/infrastructure/utils/request-response-utils.js'; @@ -68,11 +71,13 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi const { birthdate, 'campaign-code': campaignCode, 'external-user-token': token } = request.payload.data.attributes; const origin = getForwardedOrigin(request.headers); + const requestedApplication = RequestedApplication.fromOrigin(origin); const accessToken = await usecases.createUserAndReconcileToOrganizationLearnerFromExternalUser({ birthdate, campaignCode, token, audience: origin, + requestedApplication, }); const scoOrganizationLearner = { diff --git a/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js b/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js index d72fb840833..83634e0a818 100644 --- a/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js +++ b/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js @@ -17,6 +17,7 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi obfuscationService, tokenService, audience, + requestedApplication, userReconciliationService, userService, authenticationMethodRepository, @@ -26,6 +27,7 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi userToCreateRepository, organizationLearnerRepository, prescriptionOrganizationLearnerRepository, + lastUserApplicationConnectionsRepository, studentRepository, }) { const campaign = await campaignRepository.getByCode(campaignCode); @@ -115,9 +117,37 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi } } const tokenUserId = userWithSamlId ? userWithSamlId.id : userId; + + await _updateUserLastConnection({ + userId: tokenUserId, + requestedApplication, + authenticationMethodRepository, + lastUserApplicationConnectionsRepository, + userLoginRepository, + }); + const accessToken = tokenService.createAccessTokenForSaml({ userId: tokenUserId, audience }); - await userLoginRepository.updateLastLoggedAt({ userId: tokenUserId }); + return accessToken; }; export { createUserAndReconcileToOrganizationLearnerFromExternalUser }; + +async function _updateUserLastConnection({ + userId, + requestedApplication, + authenticationMethodRepository, + lastUserApplicationConnectionsRepository, + userLoginRepository, +}) { + await userLoginRepository.updateLastLoggedAt({ userId }); + await lastUserApplicationConnectionsRepository.upsert({ + userId, + application: requestedApplication.applicationName, + lastLoggedAt: new Date(), + }); + await authenticationMethodRepository.updateLastLoggedAtByIdentityProvider({ + userId, + identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code, + }); +} diff --git a/api/lib/domain/usecases/index.js b/api/lib/domain/usecases/index.js index 92662687954..9f0144cfb76 100644 --- a/api/lib/domain/usecases/index.js +++ b/api/lib/domain/usecases/index.js @@ -103,6 +103,7 @@ const oidcAuthenticationServiceRegistry = new OidcAuthenticationServiceRegistry( * @typedef {userRepository} UserRepository * @typedef {certificationChallengesService} CertificationChallengesService * @typedef {assessmentRepository} AssessmentRepository + * @typedef {LastUserApplicationConnectionsRepository} LastUserApplicationConnectionsRepository */ const dependencies = { accountRecoveryDemandRepository, diff --git a/api/src/identity-access-management/application/saml/saml.controller.js b/api/src/identity-access-management/application/saml/saml.controller.js index ace2f09d205..66a0a31b0f7 100644 --- a/api/src/identity-access-management/application/saml/saml.controller.js +++ b/api/src/identity-access-management/application/saml/saml.controller.js @@ -3,7 +3,7 @@ import { tokenService } from '../../../shared/domain/services/token-service.js'; import { logger } from '../../../shared/infrastructure/utils/logger.js'; import { usecases } from '../../domain/usecases/index.js'; import * as saml from '../../infrastructure/saml.js'; -import { getForwardedOrigin } from '../../infrastructure/utils/network.js'; +import { getForwardedOrigin, RequestedApplication } from '../../infrastructure/utils/network.js'; const metadata = function (request, h) { return h.response(saml.getServiceProviderMetadata()).type('application/xml'); @@ -24,11 +24,13 @@ const assert = async function (request, h) { try { const origin = getForwardedOrigin(request.headers); + const requestedApplication = RequestedApplication.fromOrigin(origin); const redirectionUrl = await usecases.getSamlAuthenticationRedirectionUrl({ userAttributes, tokenService, config, audience: origin, + requestedApplication, }); return h.redirect(redirectionUrl); diff --git a/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js b/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js index 078d0c02b89..6d5c9304e7e 100644 --- a/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js +++ b/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js @@ -6,9 +6,11 @@ const getSamlAuthenticationRedirectionUrl = async function ({ userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config, audience, + requestedApplication, }) { const { attributeMapping } = config.saml; const externalUser = { @@ -18,15 +20,30 @@ const getSamlAuthenticationRedirectionUrl = async function ({ }; const user = await userRepository.getBySamlId(externalUser.samlId); - if (user) { - return await _getUrlWithAccessToken({ + await _updateUserLastConnection({ + user, + requestedApplication, + authenticationMethodRepository, + lastUserApplicationConnectionsRepository, + userLoginRepository, + }); + + await _saveUserFirstAndLastName({ + authenticationMethodRepository, + user, + externalUser, + }); + + return _getUrlWithAccessToken({ user, audience, externalUser, tokenService, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, + requestedApplication, }); } @@ -35,20 +52,27 @@ const getSamlAuthenticationRedirectionUrl = async function ({ export { getSamlAuthenticationRedirectionUrl }; -async function _getUrlWithAccessToken({ - user, - audience, - externalUser, - tokenService, - userLoginRepository, - authenticationMethodRepository, -}) { +async function _getUrlWithAccessToken({ user, audience, tokenService }) { const token = tokenService.createAccessTokenForSaml({ userId: user.id, audience }); - await userLoginRepository.updateLastLoggedAt({ userId: user.id }); - await _saveUserFirstAndLastName({ authenticationMethodRepository, user, externalUser }); + return `/connexion/gar#${encodeURIComponent(token)}`; } +function _externalUserFirstAndLastNameMatchesAuthenticationMethodFirstAndLastName({ + authenticationMethod, + externalUser, +}) { + return ( + externalUser.firstName === authenticationMethod.authenticationComplement?.firstName && + externalUser.lastName === authenticationMethod.authenticationComplement?.lastName + ); +} + +function _getUrlForReconciliationPage({ tokenService, externalUser }) { + const externalUserToken = tokenService.createIdTokenForUserReconciliation(externalUser); + return `/campagnes?externalUser=${encodeURIComponent(externalUserToken)}`; +} + async function _saveUserFirstAndLastName({ authenticationMethodRepository, user, externalUser }) { const authenticationMethod = await authenticationMethodRepository.findOneByUserIdAndIdentityProvider({ userId: user.id, @@ -69,17 +93,21 @@ async function _saveUserFirstAndLastName({ authenticationMethodRepository, user, authenticationMethodRepository.update(authenticationMethod); } -function _externalUserFirstAndLastNameMatchesAuthenticationMethodFirstAndLastName({ - authenticationMethod, - externalUser, +async function _updateUserLastConnection({ + user, + requestedApplication, + authenticationMethodRepository, + lastUserApplicationConnectionsRepository, + userLoginRepository, }) { - return ( - externalUser.firstName === authenticationMethod.authenticationComplement?.firstName && - externalUser.lastName === authenticationMethod.authenticationComplement?.lastName - ); -} - -function _getUrlForReconciliationPage({ tokenService, externalUser }) { - const externalUserToken = tokenService.createIdTokenForUserReconciliation(externalUser); - return `/campagnes?externalUser=${encodeURIComponent(externalUserToken)}`; + await userLoginRepository.updateLastLoggedAt({ userId: user.id }); + await lastUserApplicationConnectionsRepository.upsert({ + userId: user.id, + application: requestedApplication.applicationName, + lastLoggedAt: new Date(), + }); + await authenticationMethodRepository.updateLastLoggedAtByIdentityProvider({ + userId: user.id, + identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code, + }); } diff --git a/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js b/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js index 3a27275c8d2..32c28e8eeee 100644 --- a/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js +++ b/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js @@ -152,7 +152,7 @@ describe('Acceptance | Controller | sco-organization-learners', function () { options = { method: 'POST', url: '/api/sco-organization-learners/external', - headers: {}, + headers: generateAuthenticatedUserRequestHeaders(), payload: {}, }; diff --git a/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js b/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js index e8d04e995f5..609f6c95311 100644 --- a/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js +++ b/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js @@ -2,15 +2,18 @@ import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../../src/identity-access- import { AuthenticationMethod } from '../../../../../src/identity-access-management/domain/models/AuthenticationMethod.js'; import { User } from '../../../../../src/identity-access-management/domain/models/User.js'; import { getSamlAuthenticationRedirectionUrl } from '../../../../../src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js'; +import { RequestedApplication } from '../../../../../src/identity-access-management/infrastructure/utils/network.js'; import { domainBuilder, expect, sinon } from '../../../../test-helper.js'; describe('Unit | UseCase | get-external-authentication-redirection-url', function () { let userRepository; let userLoginRepository; let authenticationMethodRepository; + let lastUserApplicationConnectionsRepository; let tokenService; let samlSettings; const audience = 'https://app.pix.fr'; + const requestedApplication = new RequestedApplication('app'); beforeEach(function () { userRepository = { @@ -23,6 +26,7 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio authenticationMethodRepository = { findOneByUserIdAndIdentityProvider: sinon.stub(), + updateLastLoggedAtByIdentityProvider: sinon.stub(), update: sinon.stub(), }; @@ -40,6 +44,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio }, }, }; + + lastUserApplicationConnectionsRepository = { + upsert: sinon.stub(), + }; }); context('when user does not exist in database yet', function () { @@ -59,6 +67,7 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userAttributes, userRepository, userLoginRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, }); @@ -107,8 +116,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, + requestedApplication, }); // then @@ -116,7 +127,7 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio expect(result).to.deep.equal(expectedUrl); }); - it('should save the last login date', async function () { + it('should save the last login dates', async function () { // given const userAttributes = { IDO: 'saml-id-for-adele', @@ -137,12 +148,23 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, + requestedApplication, }); // then expect(userLoginRepository.updateLastLoggedAt).to.have.been.calledWithExactly({ userId: 777 }); + expect(lastUserApplicationConnectionsRepository.upsert).to.have.been.calledWithExactly({ + userId: 777, + application: 'app', + lastLoggedAt: sinon.match.instanceOf(Date), + }); + expect(authenticationMethodRepository.updateLastLoggedAtByIdentityProvider).to.have.been.calledWithExactly({ + userId: 777, + identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code, + }); }); context("when user's authentication method does not contain first and last name", function () { @@ -170,8 +192,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, + requestedApplication, }); // then @@ -207,8 +231,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, + requestedApplication, }); // then @@ -243,8 +269,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, + requestedApplication, }); // then @@ -279,8 +307,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio userRepository, userLoginRepository, authenticationMethodRepository, + lastUserApplicationConnectionsRepository, tokenService, config: samlSettings, + requestedApplication, }); // then diff --git a/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js b/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js index 27317e34ca7..62684d00844 100644 --- a/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js +++ b/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js @@ -1,5 +1,6 @@ import { usecases } from '../../../../lib/domain/usecases/index.js'; import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../src/identity-access-management/domain/constants/identity-providers.js'; +import { RequestedApplication } from '../../../../src/identity-access-management/infrastructure/utils/network.js'; import { CampaignCodeError, NotFoundError, @@ -10,6 +11,13 @@ import { tokenService } from '../../../../src/shared/domain/services/token-servi import { catchErr, databaseBuilder, expect, knex } from '../../../test-helper.js'; describe('Integration | UseCases | create-user-and-reconcile-to-organization-learner-from-external-user', function () { + let audience, requestedApplication; + + beforeEach(async function () { + audience = 'https://app.pix.fr'; + requestedApplication = new RequestedApplication('app'); + }); + context('When there is no campaign with the given code', function () { it('should throw a campaign code error', async function () { // when @@ -163,6 +171,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea campaignCode, token, tokenService, + audience, + requestedApplication, }); // then @@ -171,11 +181,16 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea const authenticationMethodInDB = await knex('authentication-methods'); const authenticationMethod = authenticationMethodInDB[0]; + const lastUserApplicationConnections = await knex('last-user-application-connections'); + const lastUserApplicationConnection = lastUserApplicationConnections[0]; expect(authenticationMethod.externalIdentifier).to.equal(samlId); expect(authenticationMethod.authenticationComplement).to.deep.equal({ firstName: 'Julie', lastName: 'Dumoulin-Lemarchand', }); + expect(authenticationMethod.identityProvider).to.equal(NON_OIDC_IDENTITY_PROVIDERS.GAR.code); + expect(lastUserApplicationConnection.userId).to.equal(authenticationMethod.userId); + expect(lastUserApplicationConnection.application).to.equal(requestedApplication.applicationName); }); context('When the external user is already reconciled to another account', function () { @@ -240,6 +255,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea campaignCode, token, birthdate: organizationLearner.birthdate, + audience, + requestedApplication, }); // then @@ -292,6 +309,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea campaignCode, token, birthdate: organizationLearner.birthdate, + audience, + requestedApplication, }); // then @@ -305,11 +324,16 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea userId: otherAccount.id, }); const authenticationMethod = authenticationMethodInDB[0]; + const lastUserApplicationConnections = await knex('last-user-application-connections'); + const lastUserApplicationConnection = lastUserApplicationConnections[0]; expect(authenticationMethod.externalIdentifier).to.equal(samlId); expect(authenticationMethod.authenticationComplement).to.deep.equal({ firstName: 'Julie', lastName: 'Dumoulin-Lemarchand', }); + expect(authenticationMethod.identityProvider).to.equal(NON_OIDC_IDENTITY_PROVIDERS.GAR.code); + expect(lastUserApplicationConnection.userId).to.equal(authenticationMethod.userId); + expect(lastUserApplicationConnection.application).to.equal(requestedApplication.applicationName); }); }); }); @@ -337,6 +361,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea birthdate: organizationLearner.birthdate, campaignCode, token, + audience, + requestedApplication, }); // then diff --git a/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js b/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js index b71ff47700c..de4d3b92984 100644 --- a/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js +++ b/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js @@ -1,4 +1,6 @@ import { createUserAndReconcileToOrganizationLearnerFromExternalUser } from '../../../../lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js'; +import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../src/identity-access-management/domain/constants/identity-providers.js'; +import { RequestedApplication } from '../../../../src/identity-access-management/infrastructure/utils/network.js'; import { domainBuilder, expect, sinon } from '../../../test-helper.js'; describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-from-external-user', function () { @@ -12,7 +14,9 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro let userLoginRepository; let organizationLearnerRepository; let studentRepository; + let lastUserApplicationConnectionsRepository; const audience = 'https://app.pix.fr'; + const requestedApplication = new RequestedApplication('app'); beforeEach(function () { campaignRepository = { @@ -35,6 +39,14 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro userService = { createAndReconcileUserToOrganizationLearner: sinon.stub(), }; + + authenticationMethodRepository = { + updateLastLoggedAtByIdentityProvider: sinon.stub(), + }; + + lastUserApplicationConnectionsRepository = { + upsert: sinon.stub(), + }; }); context('when user has saml id', function () { @@ -66,10 +78,21 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro userLoginRepository, organizationLearnerRepository, studentRepository, + lastUserApplicationConnectionsRepository, + requestedApplication, }); // then expect(userLoginRepository.updateLastLoggedAt).to.have.been.calledWithExactly({ userId: user.id }); + expect(lastUserApplicationConnectionsRepository.upsert).to.have.been.calledWithExactly({ + userId: user.id, + application: requestedApplication.applicationName, + lastLoggedAt: sinon.match.date, + }); + expect(authenticationMethodRepository.updateLastLoggedAtByIdentityProvider).to.have.been.calledWithExactly({ + userId: user.id, + identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code, + }); }); it('should return an access token', async function () { @@ -103,6 +126,8 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro userLoginRepository, organizationLearnerRepository, studentRepository, + lastUserApplicationConnectionsRepository, + requestedApplication, }); // then @@ -111,7 +136,7 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro }); context('when user does not have saml id', function () { - it('should save last login date', async function () { + it('should save last login dates', async function () { // given const user = domainBuilder.buildUser(); const organizationLearner = domainBuilder.buildOrganizationLearner(user); @@ -140,6 +165,8 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro userLoginRepository, organizationLearnerRepository, studentRepository, + lastUserApplicationConnectionsRepository, + requestedApplication, }); // then @@ -178,6 +205,8 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro userLoginRepository, organizationLearnerRepository, studentRepository, + lastUserApplicationConnectionsRepository, + requestedApplication, }); // then