diff --git a/api/sample.env b/api/sample.env index 09e5aff77c8..8ae669fab3a 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/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 8f09553de0e..f91850c4ddd 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); + }); + }); +});