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
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 All @@ -75,7 +100,7 @@ export class RegisterOrLoginAzureUseCase implements RegisterOrLoginAzureUseCaseI
userToAuthenticate.avatar = avatarUrl;
}

return signIn(userToAuthenticate, this.getTokenService, 'azure');
return await signIn(userToAuthenticate, this.getTokenService, 'azure');
}
joaofrparreira marked this conversation as resolved.
Show resolved Hide resolved

private async getUserPhoto(user: User) {
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;
};
Loading
Loading