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

[FEATURE] Créer une fonction de révocation des accès utilisateurs (PIX-15947) #11270

Merged
merged 3 commits into from
Jan 31, 2025
Merged
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
9 changes: 8 additions & 1 deletion api/lib/infrastructure/authentication.js
EmmanuelleBonnemay marked this conversation as resolved.
Show resolved Hide resolved
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();
}
EmmanuelleBonnemay marked this conversation as resolved.
Show resolved Hide resolved

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'),
lego-technix marked this conversation as resolved.
Show resolved Hide resolved
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