Skip to content

Commit

Permalink
fix: issue-1593- validate access token on login register (#1595)
Browse files Browse the repository at this point in the history
  • Loading branch information
joaofrparreira authored Nov 26, 2024
1 parent 6371b9c commit 681f14c
Show file tree
Hide file tree
Showing 16 changed files with 494 additions and 71 deletions.
254 changes: 206 additions & 48 deletions backend/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"dotenv": "^16.0.0",
"express": "^4.17.3",
"joi": "^17.6.0",
"jwks-rsa": "^3.1.0",
"jwt-decode": "^3.1.2",
"lint-staged": "^13.0.3",
"moment": "^2.29.4",
Expand Down Expand Up @@ -113,4 +114,4 @@
"prettier --write"
]
}
}
}
3 changes: 2 additions & 1 deletion backend/src/infrastructure/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export const configuration = (): Configuration => {
clientSecret: process.env.AZURE_CLIENT_SECRET as string,
tenantId: process.env.AZURE_TENANT_ID as string,
enabled: process.env.AZURE_ENABLE === 'true',
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
wellknown: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/.well-known/openid-configuration`
},
smtp: {
host: process.env.SMTP_HOST as string,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/libs/constants/azure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export const AZURE_CLIENT_SECRET = 'azure.clientSecret';
export const AZURE_TENANT_ID = 'azure.tenantId';

export const AZURE_AUTHORITY = 'azure.authority';

export const AZURE_WELLKNOWN = 'azure.wellknown';
22 changes: 22 additions & 0 deletions backend/src/libs/test-utils/mocks/factories/azure-user-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import faker from '@faker-js/faker';
import { buildTestFactory } from './generic-factory.mock';
import { AzureUserDTO } from 'src/modules/azure/dto/azure-user.dto';

const mockUserData = (): AzureUserDTO => {
const mail = faker.internet.email();

return {
id: faker.datatype.uuid(),
displayName: faker.name.firstName() + faker.name.lastName(),
mail: mail,
userPrincipalName: mail,
createdDateTime: faker.date.past(5),
accountEnabled: faker.datatype.boolean(),
deletedDateTime: faker.datatype.boolean() ? faker.date.recent(1) : null,
employeeLeaveDateTime: faker.datatype.boolean() ? faker.date.recent(1) : null
};
};

export const AzureUserFactory = buildTestFactory<AzureUserDTO>(() => {
return mockUserData();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { UseCase } from 'src/libs/interfaces/use-case.interface';
import { Test, TestingModule } from '@nestjs/testing';
import LoggedUserDto from 'src/modules/users/dto/logged.user.dto';
import { registerOrLoginUseCase } from '../azure.providers';
import { AUTH_AZURE_SERVICE } from '../constants';
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
import { CREATE_USER_SERVICE, GET_USER_SERVICE } from 'src/modules/users/constants';
import { GetUserServiceInterface } from 'src/modules/users/interfaces/services/get.user.service.interface';
import { CreateUserServiceInterface } from 'src/modules/users/interfaces/services/create.user.service.interface';
import { GET_TOKEN_AUTH_SERVICE, UPDATE_USER_SERVICE } from 'src/modules/auth/constants';
import { UpdateUserServiceInterface } from 'src/modules/users/interfaces/services/update.user.service.interface';
import { GetTokenAuthServiceInterface } from 'src/modules/auth/interfaces/services/get-token.auth.service.interface';
import { STORAGE_SERVICE } from 'src/modules/storage/constants';
import { StorageServiceInterface } from 'src/modules/storage/interfaces/services/storage.service';
import { ConfigService } from '@nestjs/config';
import configService from 'src/libs/test-utils/mocks/configService.mock';
import { JwtService } from '@nestjs/jwt';

describe('RegisterOrLoginUserUseCase', () => {
let registerOrLogin: UseCase<string, LoggedUserDto | null>;
let authAzureServiceMock: DeepMocked<AuthAzureServiceInterface>;
let getUserService: DeepMocked<GetUserServiceInterface>;
let updateUserServiceMock: DeepMocked<UpdateUserServiceInterface>;
let tokenServiceMock: DeepMocked<GetTokenAuthServiceInterface>;

beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
registerOrLoginUseCase,
{
provide: AUTH_AZURE_SERVICE,
useValue: createMock<AuthAzureServiceInterface>()
},
{
provide: GET_USER_SERVICE,
useValue: createMock<GetUserServiceInterface>()
},
{
provide: CREATE_USER_SERVICE,
useValue: createMock<CreateUserServiceInterface>()
},
{
provide: UPDATE_USER_SERVICE,
useValue: createMock<UpdateUserServiceInterface>()
},
{
provide: GET_TOKEN_AUTH_SERVICE,
useValue: createMock<GetTokenAuthServiceInterface>()
},
{
provide: STORAGE_SERVICE,
useValue: createMock<StorageServiceInterface>()
},
{
provide: ConfigService,
useValue: configService
},
{
provide: JwtService,
useValue: createMock<JwtService>()
}
]
}).compile();

registerOrLogin = module.get(registerOrLoginUseCase.provide);
authAzureServiceMock = module.get(AUTH_AZURE_SERVICE);
getUserService = module.get(GET_USER_SERVICE);
updateUserServiceMock = module.get(UPDATE_USER_SERVICE);
tokenServiceMock = module.get(GET_TOKEN_AUTH_SERVICE);
});

beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});

it('should be defined', () => {
expect(registerOrLogin).toBeDefined();
});
describe('execute', () => {
it('should return null when validateAccessToken returns false', async () => {
const spy = jest
.spyOn(registerOrLogin, 'validateAccessToken' as any)
.mockResolvedValueOnce(false);
expect(await registerOrLogin.execute('')).toBe(null);
spy.mockRestore();
});
it('should restore user when is deleted and signin normally', async () => {
const spy = jest.spyOn(registerOrLogin, 'validateAccessToken' as any).mockResolvedValueOnce({
unique_name: 'test',
email: '[email protected]',
name: 'test',
given_name: 'test',
family_name: 'test'
});
authAzureServiceMock.getUserFromAzure.mockResolvedValueOnce({
accountEnabled: true,
deletedDateTime: null
} as never);
getUserService.getByEmail.mockResolvedValueOnce({
_id: 'id',
email: '[email protected]',
isDeleted: true
} as never);
tokenServiceMock.getTokens.mockResolvedValueOnce({} as never);
expect(await registerOrLogin.execute('')).toHaveProperty('email', '[email protected]');
expect(updateUserServiceMock.restoreUser).toHaveBeenCalled();
spy.mockRestore();
});
it('should singIn the user', async () => {
const spy = jest.spyOn(registerOrLogin, 'validateAccessToken' as any).mockResolvedValueOnce({
unique_name: 'test',
email: '[email protected]',
name: 'test',
given_name: 'test',
family_name: 'test'
});
authAzureServiceMock.getUserFromAzure.mockResolvedValueOnce({
accountEnabled: true,
deletedDateTime: null
} as never);
getUserService.getByEmail.mockResolvedValueOnce({
_id: 'id',
email: '[email protected]',
isDeleted: false
} as never);
tokenServiceMock.getTokens.mockResolvedValueOnce({} as never);
expect(await registerOrLogin.execute('')).toHaveProperty('email', '[email protected]');
spy.mockRestore();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { RegisterOrLoginAzureUseCaseInterface } from '../interfaces/applications/register-or-login.azure.use-case.interface';
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
import { AUTH_AZURE_SERVICE } from '../constants';
import { AzureDecodedUser } from '../services/auth.azure.service';
import jwt_decode from 'jwt-decode';
import { GetUserServiceInterface } from 'src/modules/users/interfaces/services/get.user.service.interface';
import { CreateUserServiceInterface } from 'src/modules/users/interfaces/services/create.user.service.interface';
import User from 'src/modules/users/entities/user.schema';
Expand All @@ -15,9 +14,16 @@ import { StorageServiceInterface } from 'src/modules/storage/interfaces/services
import { CREATE_USER_SERVICE, GET_USER_SERVICE } from 'src/modules/users/constants';
import { STORAGE_SERVICE } from 'src/modules/storage/constants';
import { GET_TOKEN_AUTH_SERVICE, UPDATE_USER_SERVICE } from 'src/modules/auth/constants';
import { JwksClient } from 'jwks-rsa';
import { AZURE_CLIENT_ID, AZURE_WELLKNOWN } from 'src/libs/constants/azure';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';

@Injectable()
export class RegisterOrLoginAzureUseCase implements RegisterOrLoginAzureUseCaseInterface {
private readonly logger: Logger = new Logger(RegisterOrLoginAzureUseCase.name);

constructor(
@Inject(AUTH_AZURE_SERVICE)
private readonly authAzureService: AuthAzureServiceInterface,
Expand All @@ -30,12 +36,19 @@ export class RegisterOrLoginAzureUseCase implements RegisterOrLoginAzureUseCaseI
@Inject(GET_TOKEN_AUTH_SERVICE)
private readonly getTokenService: GetTokenAuthServiceInterface,
@Inject(STORAGE_SERVICE)
private readonly storageService: StorageServiceInterface
private readonly storageService: StorageServiceInterface,
private readonly configService: ConfigService,
private readonly jwtService: JwtService
) {}

async execute(azureToken: string) {
const validAccessToken = await this.validateAccessToken(azureToken);

if (!validAccessToken) {
return null;
}
const { unique_name, email, name, given_name, family_name } = <AzureDecodedUser>(
jwt_decode(azureToken)
validAccessToken
);

const emailOrUniqueName = email ?? unique_name;
Expand All @@ -44,11 +57,23 @@ export class RegisterOrLoginAzureUseCase implements RegisterOrLoginAzureUseCaseI

if (!userFromAzure) return null;

const user = await this.getUserService.getByEmail(emailOrUniqueName);
//This will check if user exists, and if the acount is disabled
if (
!userFromAzure ||
!userFromAzure.accountEnabled ||
(userFromAzure.deletedDateTime !== null && userFromAzure.deletedDateTime <= new Date())
) {
return null;
}

const user = await this.getUserService.getByEmail(emailOrUniqueName, true);

let userToAuthenticate: User;

if (user) {
if (user.isDeleted) {
await this.updateUserService.restoreUser(user._id);
}
userToAuthenticate = user;
} else {
const splitedName = name ? name.split(' ') : [];
Expand Down Expand Up @@ -107,4 +132,54 @@ export class RegisterOrLoginAzureUseCase implements RegisterOrLoginAzureUseCaseI
return '';
}
}

/**
* Validate Azure access token using issuer public key
* @param token
* @returns false or decoded token payload
*/
private async validateAccessToken(token: string): Promise<Record<string, any> | boolean> {
try {
//Use wellknown to get issuer and jwks uri's
const wellKnown = this.configService.get(AZURE_WELLKNOWN);

const { data } = await axios.get(wellKnown);

const client = new JwksClient({
jwksUri: data.jwks_uri
});

const { header } = this.jwtService.decode(token, { complete: true }) as {
header: any;
payload: any;
signature: any;
};

if (!header) {
return false;
}

const secret = await client.getSigningKey(header.kid);

const decodedToken = await this.jwtService.verifyAsync(token, {
algorithms: ['RS256'],
audience: this.configService.get(AZURE_CLIENT_ID),
secret: secret.getPublicKey(),
complete: true,
issuer: data.issuer
});

if (decodedToken) {
const { payload } = decodedToken;

return payload;
}
} catch (err) {
this.logger.error(
`An error occurred while validating azure access token. Message: ${err.message}`
);
}

return false;
}
}
3 changes: 2 additions & 1 deletion backend/src/modules/azure/azure.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { StorageModule } from '../storage/storage.module';
import UsersModule from '../users/users.module';
import { authAzureService, checkUserUseCase, registerOrLoginUseCase } from './azure.providers';
import AzureController from './controller/azure.controller';
import { JwtRegister } from 'src/infrastructure/config/jwt.register';

@Module({
imports: [UsersModule, AuthModule, CommunicationModule, StorageModule],
imports: [UsersModule, AuthModule, CommunicationModule, StorageModule, JwtRegister],
controllers: [AzureController],
providers: [authAzureService, checkUserUseCase, registerOrLoginUseCase]
})
Expand Down
10 changes: 10 additions & 0 deletions backend/src/modules/azure/dto/azure-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type AzureUserDTO = {
id: string;
mail: string;
displayName: string;
userPrincipalName: string;
createdDateTime: Date;
accountEnabled: boolean;
deletedDateTime: Date | null;
employeeLeaveDateTime: Date | null;
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AzureUserFound } from '../../services/auth.azure.service';
import { AzureUserDTO } from '../../dto/azure-user.dto';

export interface AuthAzureServiceInterface {
getUserFromAzure(email: string): Promise<AzureUserFound | undefined>;
getUserFromAzure(email: string): Promise<AzureUserDTO | undefined>;
fetchUserPhoto(userId: string): Promise<any>;
}
22 changes: 12 additions & 10 deletions backend/src/modules/azure/services/auth.azure.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@ import { ConfidentialClientApplication } from '@azure/msal-node';
import { Client } from '@microsoft/microsoft-graph-client';
import { ConfigService } from '@nestjs/config';
import { AZURE_AUTHORITY, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } from 'src/libs/constants/azure';

export type AzureUserFound = {
id: string;
mail: string;
displayName: string;
userPrincipalName: string;
createdDateTime: Date;
};
import { AzureUserDTO } from '../dto/azure-user.dto';

export type AzureDecodedUser = {
unique_name: string;
Expand Down Expand Up @@ -54,10 +47,19 @@ export default class AuthAzureService implements AuthAzureServiceInterface {
});
}

async getUserFromAzure(email: string): Promise<AzureUserFound | undefined> {
async getUserFromAzure(email: string): Promise<AzureUserDTO | undefined> {
const { value } = await this.graphClient
.api('/users')
.select(['id', 'displayName', 'mail', 'userPrincipalName', 'createdDateTime'])
.select([
'id',
'mail',
'displayName',
'userPrincipalName',
'createdDateTime',
'accountEnabled',
'deletedDateTime',
'employeeLeaveDateTime'
])
.search(`"mail:${email}" OR "displayName:${email}" OR "userPrincipalName:${email}"`)
.orderby('displayName')
.get();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface GetUserServiceInterface {
userId: string
): Promise<LeanDocument<UserDocument> | false>;

getByEmail(email: string): Promise<User>;
getByEmail(email: string, checkDeleted?: boolean): Promise<User>;

getById(id: string): Promise<User>;

Expand Down
Loading

0 comments on commit 681f14c

Please sign in to comment.