Skip to content

Commit

Permalink
[FEATURE] Créer une fonction de révocation des accès utilisateurs (PI…
Browse files Browse the repository at this point in the history
  • Loading branch information
pix-service-auto-merge authored Jan 31, 2025
2 parents 49ab8b8 + c4183a4 commit 03726ca
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 1 deletion.
9 changes: 8 additions & 1 deletion api/lib/infrastructure/authentication.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions api/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions api/src/identity-access-management/domain/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -87,6 +99,8 @@ export {
OrganizationLearnerNotBelongToOrganizationIdentityError,
PasswordNotMatching,
PasswordResetDemandNotFoundError,
RevokeUntilMustBeAnInstanceOfDate,
UserCantBeCreatedError,
UserIdIsRequiredError,
UserShouldChangePasswordError,
};
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions api/src/shared/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
41 changes: 41 additions & 0 deletions api/tests/unit/infrastructure/authentication_test.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 03726ca

Please sign in to comment.