Skip to content

Commit

Permalink
handle multiple tokenization strategies
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Aug 6, 2023
1 parent d5598f5 commit 26c8794
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 43 deletions.
4 changes: 2 additions & 2 deletions api/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion api/src/modules/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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: [
Expand Down
36 changes: 24 additions & 12 deletions api/src/modules/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<JwtDataPayload> = {
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,
}),
};
}

Expand All @@ -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,
},
);
}
Expand Down
49 changes: 29 additions & 20 deletions api/src/modules/authentication/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -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<User> {
const user: User | null = await this.userRepository.findByEmail(email);

async validate(payload: JwtDataPayload): Promise<any> {
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();
}
}
}
14 changes: 14 additions & 0 deletions api/src/modules/authentication/utils/authentication.utils.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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');
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')
Expand Down
113 changes: 113 additions & 0 deletions api/test/e2e/tokenization-strategies/tokenization-strategies.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(
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(
'[email protected]',
{ tokenType: TOKEN_TYPE.ACCOUNT_ACTIVATION },
);

await request(testApplication.getHttpServer())
.post('/api/v1/users/me/password/reset')
.set('Authorization', `Bearer ${accountActivationToken}`)
.send({ newPassword: `[email protected]` })
.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(
'[email protected]',
{ tokenType: TOKEN_TYPE.PASSWORD_RESET },
);

await request(testApplication.getHttpServer())
.post('/auth/validate-account')
.set('Authorization', `Bearer ${passwordResetToken}`)
.send({ newPassword: `[email protected]` })
.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(
'[email protected]',
{ tokenType: TOKEN_TYPE.GENERAL },
);

await request(testApplication.getHttpServer())
.post('/auth/validate-account')
.set('Authorization', `Bearer ${loginToken}`)
.send({ newPassword: `[email protected]` })
.expect(HttpStatus.UNAUTHORIZED);

await request(testApplication.getHttpServer())
.post('/api/v1/users/me/password/reset')
.set('Authorization', `Bearer ${loginToken}`)
.send({ newPassword: `[email protected]` })
.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(
'[email protected]',
{ tokenType: TOKEN_TYPE.ACCOUNT_ACTIVATION },
);

const passwordResetToken: string = authenticationService.signToken(
'[email protected]',
{ tokenType: TOKEN_TYPE.PASSWORD_RESET },
);

await request(testApplication.getHttpServer())
.get('/api/v1/materials')
.set('Authorization', `Bearer ${accountActivationToken}`)
.send({ newPassword: `[email protected]` })
.expect(HttpStatus.UNAUTHORIZED);

await request(testApplication.getHttpServer())
.get('/api/v1/materials')
.set('Authorization', `Bearer ${passwordResetToken}`)
.send({ newPassword: `[email protected]` })
.expect(HttpStatus.UNAUTHORIZED);
},
);
});
});
13 changes: 12 additions & 1 deletion infrastructure/kubernetes/modules/aws/env/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 19 additions & 6 deletions infrastructure/kubernetes/modules/aws/secrets/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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}"
Expand All @@ -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
}
}

Expand Down

0 comments on commit 26c8794

Please sign in to comment.