diff --git a/api/lib/infrastructure/authentication.js b/api/lib/infrastructure/authentication.js index cd4ac21f616..105811b76c2 100644 --- a/api/lib/infrastructure/authentication.js +++ b/api/lib/infrastructure/authentication.js @@ -1,6 +1,7 @@ import boom from '@hapi/boom'; import lodash from 'lodash'; +import { revokedUserAccessRepository } from '../../src/identity-access-management/infrastructure/repositories/revoked-user-access.repository.js'; import { getForwardedOrigin } from '../../src/identity-access-management/infrastructure/utils/network.js'; import { config } from '../../src/shared/config.js'; import { tokenService } from '../../src/shared/domain/services/token-service.js'; @@ -90,7 +91,13 @@ async function _checkIsAuthenticated(request, h, { key, validate }) { // Only tokens including user_id are User Access Tokens. // This is why applications Access Tokens are not subject to audience validation for now. - if (decodedAccessToken.user_id && config.featureToggles.isUserTokenAudConfinementEnabled) { + const userId = decodedAccessToken.user_id; + if (config.featureToggles.isUserTokenAudConfinementEnabled && userId) { + const revokedUserAccess = await revokedUserAccessRepository.findByUserId(userId); + if (revokedUserAccess.isAccessTokenRevoked(decodedAccessToken)) { + return boom.unauthorized(); + } + const audience = getForwardedOrigin(request.headers); if (decodedAccessToken.aud !== audience) { return boom.unauthorized(); diff --git a/api/sample.env b/api/sample.env index c5988a2eb8a..f2cdc09f3ce 100644 --- a/api/sample.env +++ b/api/sample.env @@ -712,6 +712,12 @@ REFRESH_TOKEN_LIFESPAN=7d # default: '7d' # REFRESH_TOKEN_LIFESPAN_PIX_ADMIN=7d +# Revoked user access lifespan +# presence: optional +# type: String +# default: '7d' +# REVOKED_USER_ACCESS_LIFESPAN=7d + # Saml access token lifespan # presence: optional # type: String diff --git a/api/src/identity-access-management/domain/errors.js b/api/src/identity-access-management/domain/errors.js index a3ba21d7c4f..a87f174acae 100644 --- a/api/src/identity-access-management/domain/errors.js +++ b/api/src/identity-access-management/domain/errors.js @@ -70,6 +70,18 @@ class UserCantBeCreatedError extends DomainError { } } +class RevokeUntilMustBeAnInstanceOfDate extends DomainError { + constructor(message = 'Revoke Until must be an instance of Date') { + super(message); + } +} + +class UserIdIsRequiredError extends DomainError { + constructor(message = 'User Id is required') { + super(message); + } +} + class UserShouldChangePasswordError extends DomainError { constructor(message = 'User password must be changed.', meta) { super(message); @@ -87,6 +99,8 @@ export { OrganizationLearnerNotBelongToOrganizationIdentityError, PasswordNotMatching, PasswordResetDemandNotFoundError, + RevokeUntilMustBeAnInstanceOfDate, UserCantBeCreatedError, + UserIdIsRequiredError, UserShouldChangePasswordError, }; diff --git a/api/src/identity-access-management/domain/models/RevokedUserAccess.js b/api/src/identity-access-management/domain/models/RevokedUserAccess.js new file mode 100644 index 00000000000..b3545b67d2d --- /dev/null +++ b/api/src/identity-access-management/domain/models/RevokedUserAccess.js @@ -0,0 +1,12 @@ +export class RevokedUserAccess { + constructor(revokeTimeStamp) { + this.revokeTimeStamp = revokeTimeStamp; + } + isAccessTokenRevoked(decodedToken) { + const issuedAt = decodedToken.iat; + if (!this.revokeTimeStamp) { + return false; + } + return issuedAt < this.revokeTimeStamp; + } +} diff --git a/api/src/identity-access-management/infrastructure/repositories/revoked-user-access.repository.js b/api/src/identity-access-management/infrastructure/repositories/revoked-user-access.repository.js new file mode 100644 index 00000000000..1e0e6edf254 --- /dev/null +++ b/api/src/identity-access-management/infrastructure/repositories/revoked-user-access.repository.js @@ -0,0 +1,31 @@ +import { config } from '../../../../src/shared/config.js'; +import { temporaryStorage } from '../../../../src/shared/infrastructure/key-value-storages/index.js'; +import { UserIdIsRequiredError } from '../../domain/errors.js'; +import { RevokeUntilMustBeAnInstanceOfDate } from '../../domain/errors.js'; +import { RevokedUserAccess } from '../../domain/models/RevokedUserAccess.js'; + +const revokedUserAccessTemporaryStorage = temporaryStorage.withPrefix('revoked-user-access:'); +const revokedUserAccessLifespanMs = config.authentication.revokedUserAccessLifespanMs; + +const saveForUser = async function (userId, revokeUntil) { + if (!userId) { + throw new UserIdIsRequiredError(); + } + + if (!(revokeUntil instanceof Date)) { + throw new RevokeUntilMustBeAnInstanceOfDate(); + } + + await revokedUserAccessTemporaryStorage.save({ + key: userId, + value: Math.floor(revokeUntil.getTime() / 1000), + expirationDelaySeconds: revokedUserAccessLifespanMs / 1000, + }); +}; + +const findByUserId = async function (userId) { + const value = await revokedUserAccessTemporaryStorage.get(userId); + return new RevokedUserAccess(value); +}; + +export const revokedUserAccessRepository = { saveForUser, findByUserId }; diff --git a/api/src/shared/config.js b/api/src/shared/config.js index d0ee6d30582..398901fc30d 100644 --- a/api/src/shared/config.js +++ b/api/src/shared/config.js @@ -161,6 +161,7 @@ const configuration = (function () { 'pix-certif': ms(process.env.REFRESH_TOKEN_LIFESPAN_PIX_CERTIF || '7d'), 'pix-admin': ms(process.env.REFRESH_TOKEN_LIFESPAN_PIX_ADMIN || '7d'), }, + revokedUserAccessLifespanMs: ms(process.env.REVOKED_USER_ACCESS_LIFESPAN || '7d'), tokenForCampaignResultLifespan: process.env.CAMPAIGN_RESULT_ACCESS_TOKEN_LIFESPAN || '1h', tokenForStudentReconciliationLifespan: '1h', passwordResetTokenLifespan: '1h', diff --git a/api/src/shared/infrastructure/validate-environment-variables.js b/api/src/shared/infrastructure/validate-environment-variables.js index 95d231cd59f..0c7208c1030 100644 --- a/api/src/shared/infrastructure/validate-environment-variables.js +++ b/api/src/shared/infrastructure/validate-environment-variables.js @@ -1,6 +1,7 @@ import Joi from 'joi'; const schema = Joi.object({ + ACCESS_TOKEN_LIFESPAN: Joi.string().optional(), AUTH_SECRET: Joi.string().required(), AUTONOMOUS_COURSES_ORGANIZATION_ID: Joi.number().required(), API_DATA_URL: Joi.string().uri().optional(), @@ -59,6 +60,7 @@ const schema = Joi.object({ POLE_EMPLOI_SENDING_URL: Joi.string().uri().optional(), POLE_EMPLOI_TOKEN_URL: Joi.string().uri().optional(), REDIS_URL: Joi.string().uri().optional(), + REVOKED_USER_ACCESS_LIFESPAN: Joi.string().optional(), SCO_ACCOUNT_RECOVERY_KEY_LIFETIME_MINUTES: Joi.number().integer().min(1).optional(), TEST_DATABASE_URL: Joi.string().optional(), TEST_LOG_ENABLED: Joi.string().optional().valid('true', 'false'), diff --git a/api/tests/identity-access-management/integration/infrastructure/repositories/revoked-user-access.repository.test.js b/api/tests/identity-access-management/integration/infrastructure/repositories/revoked-user-access.repository.test.js new file mode 100644 index 00000000000..d6864a0f6f4 --- /dev/null +++ b/api/tests/identity-access-management/integration/infrastructure/repositories/revoked-user-access.repository.test.js @@ -0,0 +1,45 @@ +import { RevokedUserAccess } from '../../../../../src/identity-access-management/domain/models/RevokedUserAccess.js'; +import { revokedUserAccessRepository } from '../../../../../src/identity-access-management/infrastructure/repositories/revoked-user-access.repository.js'; +import { temporaryStorage } from '../../../../../src/shared/infrastructure/key-value-storages/index.js'; +import { expect } from '../../../../test-helper.js'; + +const revokedUserAccessTemporaryStorage = temporaryStorage.withPrefix('revoked-user-access:'); + +describe('Integration | Identity Access Management | Infrastructure | Repository | revoked-user', function () { + beforeEach(async function () { + await revokedUserAccessTemporaryStorage.flushAll(); + }); + + describe('#saveForUser', function () { + it('saves revoked user access in Redis', async function () { + // given + const revokeUntil = new Date(); + const revokedTimeStamp = Math.floor(revokeUntil.getTime() / 1000); + + // when + await revokedUserAccessRepository.saveForUser(12345, revokeUntil); + + // then + const result = await revokedUserAccessTemporaryStorage.get(12345); + expect(result).to.equal(revokedTimeStamp); + }); + }); + + describe('#findByUserId', function () { + it('finds revoked user access by user id', async function () { + // given + const revokeUntil = new Date(); + const revokeTimeStamp = Math.floor(new Date().getTime() / 1000); + await revokedUserAccessRepository.saveForUser(12345, revokeUntil); + + // when + const result = await revokedUserAccessRepository.findByUserId(12345); + + // then + expect(result).to.deep.equal({ + revokeTimeStamp: revokeTimeStamp, + }); + expect(result).to.be.instanceOf(RevokedUserAccess); + }); + }); +}); diff --git a/api/tests/identity-access-management/unit/domain/models/RevokedUserAccess.test.js b/api/tests/identity-access-management/unit/domain/models/RevokedUserAccess.test.js new file mode 100644 index 00000000000..8d8832d9fd4 --- /dev/null +++ b/api/tests/identity-access-management/unit/domain/models/RevokedUserAccess.test.js @@ -0,0 +1,48 @@ +import { RevokedUserAccess } from '../../../../../src/identity-access-management/domain/models/RevokedUserAccess.js'; +import { expect } from '../../../../test-helper.js'; + +describe('Unit | Identity Access Management | Domain | Model | RevokedUserAccess', function () { + describe('#constructor', function () { + it('builds a revoke user access model', function () { + //when + const revokeTimeStamp = Math.floor(new Date().getTime() / 1000); + const revokedUserAccess = new RevokedUserAccess(revokeTimeStamp); + + //then + expect(revokedUserAccess.revokeTimeStamp).to.equal(revokeTimeStamp); + }); + }); + + describe('#isAccessTokenRevoked', function () { + context('when access token is revoked', function () { + it('returns true', function () { + //given + const revokeTimeStamp = Math.floor(new Date('2024-12-01').getTime() / 1000); + const iat = Math.floor(new Date('2024-11-01').getTime() / 1000); + const decodedToken = { iat: iat }; + const revokedUserAccess = new RevokedUserAccess(revokeTimeStamp); + + //when + const result = revokedUserAccess.isAccessTokenRevoked(decodedToken); + + //then + expect(result).to.equal(true); + }); + }); + context('when access token is not revoked', function () { + it('returns false', function () { + //given + const revokeTimeStamp = Math.floor(new Date('2024-10-01').getTime() / 1000); + const iat = Math.floor(new Date('2024-12-01').getTime() / 1000); + const decodedToken = { iat: iat }; + const revokedUserAccess = new RevokedUserAccess(revokeTimeStamp); + + //when + const result = revokedUserAccess.isAccessTokenRevoked(decodedToken); + + //then + expect(result).to.equal(false); + }); + }); + }); +}); diff --git a/api/tests/unit/infrastructure/authentication_test.js b/api/tests/unit/infrastructure/authentication_test.js index 623c3c51d48..6ea34e8f77f 100644 --- a/api/tests/unit/infrastructure/authentication_test.js +++ b/api/tests/unit/infrastructure/authentication_test.js @@ -1,4 +1,6 @@ import { authentication } from '../../../lib/infrastructure/authentication.js'; +import { RevokedUserAccess } from '../../../src/identity-access-management/domain/models/RevokedUserAccess.js'; +import { revokedUserAccessRepository } from '../../../src/identity-access-management/infrastructure/repositories/revoked-user-access.repository.js'; import { tokenService } from '../../../src/shared/domain/services/token-service.js'; import { expect, sinon } from '../../test-helper.js'; @@ -115,6 +117,45 @@ describe('Unit | Infrastructure | Authentication', function () { }); }); + describe('when the user access is revoked', function () { + it('should throw an error', async function () { + // given + const date = new Date(); + const revokedUserAccess = new RevokedUserAccess(date.getTime() / 1000); + sinon.stub(revokedUserAccessRepository, 'findByUserId').resolves(revokedUserAccess); + sinon.stub(revokedUserAccess, 'isAccessTokenRevoked').returns(true); + + const request = { + headers: { + authorization: 'Bearer token', + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'app.pix.fr', + }, + }; + const h = { authenticated: sinon.stub() }; + tokenService.extractTokenFromAuthChain.withArgs('Bearer token').returns('token'); + tokenService.getDecodedToken.withArgs('token', 'dummy-secret').returns({ + user_id: 'user_id', + aud: 'https://app.pix.fr', + }); + + // when + const { authenticate } = authentication.scheme(undefined, { + key: 'dummy-secret', + validate: sinon.stub().returns({ isValid: true, credentials: {}, errorCode: null }), + }); + const response = await authenticate(request, h); + + // then + expect(h.authenticated).to.not.have.been.called; + expect(response.output.payload).to.include({ + statusCode: 401, + error: 'Unauthorized', + message: 'Unauthorized', + }); + }); + }); + describe('when there is no forwarded origin in the request', function () { it('should throw an error', async function () { // given