diff --git a/api/config/custom-environment-variables.json b/api/config/custom-environment-variables.json index 55a295cbba..7c713ec1fc 100644 --- a/api/config/custom-environment-variables.json +++ b/api/config/custom-environment-variables.json @@ -13,8 +13,8 @@ "jwt": { "expiresIn": "JWT_EXPIRES_IN", "secret": "JWT_SECRET", - "accountActivationSecret": "JWT_ACTIVATION_SECRET", - "passwordRecoverySecret": "JWT_RESET_SECRET" + "accountActivationSecret": "JWT_ACCOUNT_ACTIVATION_SECRET", + "passwordRecoverySecret": "JWT_PASSWORD_RESET_SECRET" }, "password": { "minLength": "PASSWORD_MIN_LENGTH", diff --git a/api/src/modules/authentication/authentication.module.ts b/api/src/modules/authentication/authentication.module.ts index e4a1bf5971..4ef4a9c9b5 100644 --- a/api/src/modules/authentication/authentication.module.ts +++ b/api/src/modules/authentication/authentication.module.ts @@ -6,7 +6,6 @@ import { UsersModule } from 'modules/users/users.module'; import * as config from 'config'; import { AuthenticationController } from 'modules/authentication/authentication.controller'; import { AuthenticationService } from 'modules/authentication/authentication.service'; -import { JwtStrategy } from 'modules/authentication/strategies/jwt.strategy'; import { LocalStrategy } from 'modules/authentication/strategies/local.strategy'; import { ApiEventsModule } from 'modules/api-events/api-events.module'; import { User } from 'modules/users/user.entity'; @@ -16,6 +15,7 @@ import { NotificationsModule } from 'modules/notifications/notifications.module' import { AppConfig } from 'utils/app.config'; import { PasswordMailService } from 'modules/authentication/password-mail.service'; import { getPasswordSettingUrl } from 'modules/authentication/utils/authentication.utils'; +import { JwtStrategy } from 'modules/authentication/strategies/jwt.strategy'; @Module({ imports: [ diff --git a/api/src/modules/authentication/authentication.service.ts b/api/src/modules/authentication/authentication.service.ts index da692f562d..092a61f411 100644 --- a/api/src/modules/authentication/authentication.service.ts +++ b/api/src/modules/authentication/authentication.service.ts @@ -26,9 +26,16 @@ import { Role } from 'modules/authorization/roles/role.entity'; import { CreateUserDTO } from 'modules/users/dto/create.user.dto'; import { AppConfig } from 'utils/app.config'; import { PasswordMailService } from 'modules/authentication/password-mail.service'; +import { getSecretByTokenType } from 'modules/authentication/utils/authentication.utils'; const DEFAULT_USER_NAME: string = 'User'; +export enum TOKEN_TYPE { + GENERAL = 'general', + ACCOUNT_ACTIVATION = 'account-activation', + PASSWORD_RESET = 'password-reset', +} + /** * Access token for the app: key user data and access token */ @@ -62,6 +69,12 @@ export interface JwtDataPayload { */ tokenId: string; + /** + * Type of issues token to determine the secret used to sign the token + */ + + tokenType: TOKEN_TYPE; + /** * Issued At: epoch timestamp in seconds, UTC. */ @@ -282,19 +295,13 @@ export class AuthenticationService { * used in the JwtStrategy to check that the token being presented by an API * client was not revoked. */ - const payload: Partial = { - sub: user.email, - tokenId: v4(), - }; return { user: UsersService.getSanitizedUserMetadata(user), - accessToken: this.jwtService.sign( - { ...payload }, - { - expiresIn: AppConfig.get('auth.jwt.expiresIn'), - }, - ), + accessToken: this.signToken(user.email, { + expiresIn: AppConfig.get('auth.jwt.expiresIn'), + tokenType: TOKEN_TYPE.GENERAL, + }), }; } @@ -318,11 +325,16 @@ export class AuthenticationService { } } - signToken(email: string, options?: { expiresIn: string }): string { + signToken( + email: string, + options?: { expiresIn?: string; tokenType?: TOKEN_TYPE }, + ): string { + const secret: string = getSecretByTokenType(options?.tokenType); return this.jwtService.sign( - { sub: email, tokenId: v4() }, + { sub: email, tokenId: v4(), tokenType: options?.tokenType }, { expiresIn: options?.expiresIn ?? AppConfig.get('auth.jwt.expiresIn'), + secret, }, ); } diff --git a/api/src/modules/authentication/strategies/jwt.strategy.ts b/api/src/modules/authentication/strategies/jwt.strategy.ts index 9c5bb4bd5f..deb9add0c6 100644 --- a/api/src/modules/authentication/strategies/jwt.strategy.ts +++ b/api/src/modules/authentication/strategies/jwt.strategy.ts @@ -1,39 +1,48 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import * as config from 'config'; -import { - AuthenticationService, - JwtDataPayload, -} from 'modules/authentication/authentication.service'; - import { ExtractJwt, Strategy } from 'passport-jwt'; +import { JwtDataPayload } from 'modules/authentication/authentication.service'; +import { getSecretByTokenType } from 'modules/authentication/utils/authentication.utils'; import { User } from 'modules/users/user.entity'; import { UserRepository } from 'modules/users/user.repository'; +import * as jwt from 'jsonwebtoken'; + +/** + * @todo: We are handling different token strategies by using secretOrKeyProvider, and the static + * getSecretFromToken method. This is not ideal, explore how to override global at route handler level + * other global or controller level guards + */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor( - private readonly authenticationService: AuthenticationService, - - private readonly userRepository: UserRepository, - ) { + constructor(private readonly userRepo: UserRepository) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: config.get('auth.jwt.secret'), + secretOrKeyProvider: JwtStrategy.getSecretFromToken, }); } - /** - * Validate that the email in the JWT payload's `sub` property matches that of - * an existing user. - */ - public async validate({ sub: email }: JwtDataPayload): Promise { - const user: User | null = await this.userRepository.findByEmail(email); - + async validate(payload: JwtDataPayload): Promise { + const { sub: email } = payload; + const user: User | null = await this.userRepo.findByEmail(email); if (!user) { throw new UnauthorizedException(); } - return user; } + + static getSecretFromToken( + req: Request, + rawJwtToken: string, + done: any, + ): void { + try { + const { tokenType } = jwt.decode(rawJwtToken) as JwtDataPayload; + const secret: string = getSecretByTokenType(tokenType); + + done(null, secret); + } catch (e) { + throw new UnauthorizedException(); + } + } } diff --git a/api/src/modules/authentication/utils/authentication.utils.ts b/api/src/modules/authentication/utils/authentication.utils.ts index 4f26abd78e..ba40b051e7 100644 --- a/api/src/modules/authentication/utils/authentication.utils.ts +++ b/api/src/modules/authentication/utils/authentication.utils.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import { AppConfig } from 'utils/app.config'; +import { TOKEN_TYPE } from 'modules/authentication/authentication.service'; const logger: Logger = new Logger('Authentication'); export const getPasswordSettingUrl = (kind: 'activation' | 'reset'): string => { @@ -18,3 +19,16 @@ export const getPasswordSettingUrl = (kind: 'activation' | 'reset'): string => { kind === 'activation' ? passwordActivationUrl : passwordResetUrl }`; }; + +export const getSecretByTokenType = (tokenType?: TOKEN_TYPE): string => { + switch (tokenType) { + case TOKEN_TYPE.ACCOUNT_ACTIVATION: + return AppConfig.get('auth.jwt.accountActivationSecret'); + case TOKEN_TYPE.PASSWORD_RESET: + return AppConfig.get('auth.jwt.passwordRecoverySecret'); + case TOKEN_TYPE.GENERAL: + return AppConfig.get('auth.jwt.secret'); + default: + return AppConfig.get('auth.jwt.secret'); + } +}; diff --git a/api/test/e2e/password-set-and-recovery/password-set-and-recovery.spec.ts b/api/test/e2e/password-set-and-recovery/password-set-and-recovery.spec.ts index fc5c109c59..e9ed73d2d9 100644 --- a/api/test/e2e/password-set-and-recovery/password-set-and-recovery.spec.ts +++ b/api/test/e2e/password-set-and-recovery/password-set-and-recovery.spec.ts @@ -9,7 +9,10 @@ import { createUser } from '../../entity-mocks'; import { User } from 'modules/users/user.entity'; import * as request from 'supertest'; -import { AuthenticationService } from 'modules/authentication/authentication.service'; +import { + AuthenticationService, + TOKEN_TYPE, +} from 'modules/authentication/authentication.service'; import { Test } from '@nestjs/testing'; import { AppModule } from '../../../src/app.module'; import { setupTestUser } from '../../utils/userAuth'; @@ -140,6 +143,7 @@ describe('Password recovery tests (e2e)', () => { }); const token: string = authenticationService.signToken(user.email, { expiresIn: '1ms', + tokenType: TOKEN_TYPE.PASSWORD_RESET, }); await request(testApplication.getHttpServer()) .post('/api/v1/users/me/password/reset') diff --git a/api/test/e2e/tokenization-strategies/tokenization-strategies.spec.ts b/api/test/e2e/tokenization-strategies/tokenization-strategies.spec.ts new file mode 100644 index 0000000000..cff6a173c0 --- /dev/null +++ b/api/test/e2e/tokenization-strategies/tokenization-strategies.spec.ts @@ -0,0 +1,113 @@ +import ApplicationManager, { + TestApplication, +} from '../../utils/application-manager'; +import { + AuthenticationService, + TOKEN_TYPE, +} from '../../../src/modules/authentication/authentication.service'; +import * as request from 'supertest'; +import { HttpStatus } from '@nestjs/common'; + +describe('Password recovery tests (e2e)', () => { + let testApplication: TestApplication; + let authenticationService: AuthenticationService; + + beforeAll(async () => { + testApplication = await ApplicationManager.init(); + authenticationService = testApplication.get( + AuthenticationService, + ); + }); + + afterAll(async () => { + await testApplication.close(); + }); + + describe('Tokenization strategies tests (e2e)', () => { + test( + 'Given I have a account activation token,' + + 'But I use it to reset the password,' + + ' Then I should not be authorized', + async () => { + const accountActivationToken: string = authenticationService.signToken( + 'fake@mail.com', + { tokenType: TOKEN_TYPE.ACCOUNT_ACTIVATION }, + ); + + await request(testApplication.getHttpServer()) + .post('/api/v1/users/me/password/reset') + .set('Authorization', `Bearer ${accountActivationToken}`) + .send({ newPassword: `test@mail.com` }) + .expect(HttpStatus.UNAUTHORIZED); + }, + ); + test( + 'Given I have a password reset token,' + + 'But I use it to activate my account,' + + ' Then I should not be authorized', + async () => { + const passwordResetToken: string = authenticationService.signToken( + 'fake@mail.com', + { tokenType: TOKEN_TYPE.PASSWORD_RESET }, + ); + + await request(testApplication.getHttpServer()) + .post('/auth/validate-account') + .set('Authorization', `Bearer ${passwordResetToken}`) + .send({ newPassword: `test@mail.com` }) + .expect(HttpStatus.UNAUTHORIZED); + }, + ); + test( + 'Given I have a login token,' + + 'But I use it to activate my account, And to reset my password' + + ' Then I should not be authorized', + async () => { + const loginToken: string = authenticationService.signToken( + 'fake@mail.com', + { tokenType: TOKEN_TYPE.GENERAL }, + ); + + await request(testApplication.getHttpServer()) + .post('/auth/validate-account') + .set('Authorization', `Bearer ${loginToken}`) + .send({ newPassword: `test@mail.com` }) + .expect(HttpStatus.UNAUTHORIZED); + + await request(testApplication.getHttpServer()) + .post('/api/v1/users/me/password/reset') + .set('Authorization', `Bearer ${loginToken}`) + .send({ newPassword: `test@mail.com` }) + .expect(HttpStatus.UNAUTHORIZED); + }, + ); + test( + 'Given I have a account activation token, or a password reset token' + + 'When I use it to get data from the API' + + ' Then I should not be authorized', + async () => { + const accountActivationToken: string = authenticationService.signToken( + 'fake@mail.com', + { tokenType: TOKEN_TYPE.ACCOUNT_ACTIVATION }, + ); + + const passwordResetToken: string = authenticationService.signToken( + 'fake@mail.com', + { tokenType: TOKEN_TYPE.PASSWORD_RESET }, + ); + + await request(testApplication.getHttpServer()) + .get('/api/v1/materials') + .set('Authorization', `Bearer ${accountActivationToken}`) + .send({ newPassword: `test@mail.com` }) + .expect(HttpStatus.UNAUTHORIZED); + + await request(testApplication.getHttpServer()) + .get('/api/v1/materials') + .set('Authorization', `Bearer ${passwordResetToken}`) + .send({ newPassword: `test@mail.com` }) + .expect(HttpStatus.UNAUTHORIZED); + }, + ); + }); +}); diff --git a/infrastructure/kubernetes/modules/aws/env/main.tf b/infrastructure/kubernetes/modules/aws/env/main.tf index 46cd69783b..d7004496c5 100644 --- a/infrastructure/kubernetes/modules/aws/env/main.tf +++ b/infrastructure/kubernetes/modules/aws/env/main.tf @@ -63,7 +63,18 @@ module "k8s_api" { name = "JWT_SECRET" secret_name = "api" secret_key = "JWT_SECRET" - }, { + }, + { + name = "JWT_ACCOUNT_ACTIVATION_SECRET" + secret_name = "api" + secret_key = "JWT_ACCOUNT_ACTIVATION_SECRET" + }, + { + name = "JWT_PASSWORD_RESET_SECRET" + secret_name = "api" + secret_key = "JWT_PASSWORD_RESET_SECRET" + }, + { name = "GMAPS_API_KEY" secret_name = "api" secret_key = "GMAPS_API_KEY" diff --git a/infrastructure/kubernetes/modules/aws/secrets/main.tf b/infrastructure/kubernetes/modules/aws/secrets/main.tf index 7f9150c284..30b3b32174 100644 --- a/infrastructure/kubernetes/modules/aws/secrets/main.tf +++ b/infrastructure/kubernetes/modules/aws/secrets/main.tf @@ -6,9 +6,11 @@ locals { } api_secret_json = { - jwt_secret = random_password.jwt_secret_generator.result - gmaps_api_key = var.gmaps_api_key - sendgrid_api_key = var.sendgrid_api_key + jwt_secret = random_password.jwt_secret_generator.result + jwt_account_activation_secret = random_password.jwt_account_activation_secret_generator.result + jwt_password_reset_secret = random_password.jwt_password_reset_secret_generator.result + gmaps_api_key = var.gmaps_api_key + sendgrid_api_key = var.sendgrid_api_key } } @@ -17,6 +19,15 @@ resource "random_password" "jwt_secret_generator" { length = 64 special = true } +resource "random_password" "jwt_account_activation_secret_generator" { + length = 64 + special = true +} +resource "random_password" "jwt_password_reset_secret_generator" { + length = 64 + special = true +} + resource "aws_secretsmanager_secret" "api_secret" { name = "api-secret-${var.namespace}" @@ -36,9 +47,11 @@ resource "kubernetes_secret" "api_secret" { } data = { - JWT_SECRET = local.api_secret_json.jwt_secret - GMAPS_API_KEY = local.api_secret_json.gmaps_api_key - SENDGRID_API_KEY = local.api_secret_json.sendgrid_api_key + JWT_SECRET = local.api_secret_json.jwt_secret + JWT_ACCOUNT_ACTIVATION_SECRET = local.api_secret_json.jwt_account_activation_secret + JWT_PASSWORD_RESET_SECRET = local.api_secret_json.jwt_password_reset_secret + GMAPS_API_KEY = local.api_secret_json.gmaps_api_key + SENDGRID_API_KEY = local.api_secret_json.sendgrid_api_key } }