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

fix: issue-1593- validate access token on login register #1595

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
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: 'test@test.com',
name: 'test',
given_name: 'test',
family_name: 'test'
});
authAzureServiceMock.getUserFromAzure.mockResolvedValueOnce({
accountEnabled: true,
deletedDateTime: null
} as never);
getUserService.getByEmail.mockResolvedValueOnce({
_id: 'id',
email: 'test@test.com',
isDeleted: true
} as never);
tokenServiceMock.getTokens.mockResolvedValueOnce({} as never);
expect(await registerOrLogin.execute('')).toHaveProperty('email', 'test@test.com');
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: 'test@test.com',
name: 'test',
given_name: 'test',
family_name: 'test'
});
authAzureServiceMock.getUserFromAzure.mockResolvedValueOnce({
accountEnabled: true,
deletedDateTime: null
} as never);
getUserService.getByEmail.mockResolvedValueOnce({
_id: 'id',
email: 'test@test.com',
isDeleted: false
} as never);
tokenServiceMock.getTokens.mockResolvedValueOnce({} as never);
expect(await registerOrLogin.execute('')).toHaveProperty('email', 'test@test.com');
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