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

feat(auth-azure): upload and set user photo on login #1132

Merged
merged 1 commit into from
Feb 21, 2023
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
639 changes: 574 additions & 65 deletions backend/package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
"pre-commit": "lint-staged"
},
"dependencies": {
"@azure/msal-node": "^1.15.0",
"@azure/storage-blob": "^12.12.0",
"@faker-js/faker": "^6.1.2",
"@microsoft/microsoft-graph-client": "^3.0.5",
"@nestjs-modules/mailer": "^1.6.1",
"@nestjs/bull": "^0.6.0",
"@nestjs/common": "^9.0.0",
Expand Down Expand Up @@ -108,4 +111,4 @@
"prettier --write"
]
}
}
}
3 changes: 2 additions & 1 deletion backend/src/infrastructure/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ import { configuration } from './configuration';
REDIS_USER: Joi.string(),
REDIS_PASSWORD: Joi.string(),
REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().required()
REDIS_PORT: Joi.number().required(),
AZURE_STORAGE_CONNECTION_STRING: Joi.string().required()
})
})
],
Expand Down
6 changes: 5 additions & 1 deletion backend/src/infrastructure/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export const configuration = (): Configuration => {
clientId: process.env.AZURE_CLIENT_ID as string,
clientSecret: process.env.AZURE_CLIENT_SECRET as string,
tenantId: process.env.AZURE_TENANT_ID as string,
enabled: process.env.AZURE_ENABLE === 'true'
enabled: process.env.AZURE_ENABLE === 'true',
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`
},
smtp: {
host: process.env.SMTP_HOST as string,
Expand All @@ -51,6 +52,9 @@ export const configuration = (): Configuration => {
password: process.env.REDIS_PASSWORD as string,
host: process.env.REDIS_HOST as string,
port: parseInt(process.env.REDIS_PORT as string, 10)
},
storage: {
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING 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 @@ -5,3 +5,5 @@ export const AZURE_CLIENT_ID = 'azure.clientId';
export const AZURE_CLIENT_SECRET = 'azure.clientSecret';

export const AZURE_TENANT_ID = 'azure.tenantId';

export const AZURE_AUTHORITY = 'azure.authority';
1 change: 1 addition & 0 deletions backend/src/libs/constants/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const CONNECTION_STRING = 'storage.connectionString';
6 changes: 0 additions & 6 deletions backend/src/modules/auth/auth.providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import AzureAuthServiceImpl from '../azure/services/cron.azure.service';
import { CreateResetTokenAuthApplicationImpl } from './applications/create-reset-token.auth.application';
import { GetTokenAuthApplicationImpl } from './applications/get-token.auth.application';
import { RegisterAuthApplicationImpl } from './applications/register.auth.application';
Expand Down Expand Up @@ -28,11 +27,6 @@ export const createResetTokenAuthService = {
useClass: CreateResetTokenAuthServiceImpl
};

export const azureAuthService = {
provide: TYPES.services.AzureAuthService,
useClass: AzureAuthServiceImpl
};

export const getTokenAuthApplication = {
provide: TYPES.applications.GetTokenAuthApplication,
useClass: GetTokenAuthApplicationImpl
Expand Down
5 changes: 3 additions & 2 deletions backend/src/modules/auth/shared/login.auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const signIn = async (
getTokenService: GetTokenAuthService | GetTokenAuthApplication,
strategy: string
) => {
const { email, firstName, lastName, _id, isSAdmin, providerAccountCreatedAt } = user;
const { email, firstName, lastName, _id, isSAdmin, providerAccountCreatedAt, avatar } = user;
const jwt = await getTokenService.getTokens(_id);

if (!jwt) return null;
Expand All @@ -22,6 +22,7 @@ export const signIn = async (
id: _id,
isSAdmin,
providerAccountCreatedAt,
_id
_id,
avatar
};
};
7 changes: 4 additions & 3 deletions backend/src/modules/azure/azure.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import AuthModule from '../auth/auth.module';
import { CommunicationModule } from '../communication/communication.module';
import { StorageModule } from '../storage/storage.module';
import UsersModule from '../users/users.module';
import { authAzureApplication, authAzureService, cronAzureService } from './azure.providers';
import { authAzureApplication, authAzureService } from './azure.providers';
import AzureController from './controller/azure.controller';

@Module({
imports: [UsersModule, AuthModule, CommunicationModule],
imports: [UsersModule, AuthModule, CommunicationModule, StorageModule],
controllers: [AzureController],
providers: [cronAzureService, authAzureService, authAzureApplication]
providers: [authAzureService, authAzureApplication]
})
export default class AzureModule {}
6 changes: 0 additions & 6 deletions backend/src/modules/azure/azure.providers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { AuthAzureApplicationImpl } from './applications/auth.azure.application';
import { TYPES } from './interfaces/types';
import AuthAzureServiceImpl from './services/auth.azure.service';
import CronAzureServiceImpl from './services/cron.azure.service';

export const cronAzureService = {
provide: TYPES.services.CronAzureService,
useClass: CronAzureServiceImpl
};

export const authAzureService = {
provide: TYPES.services.AuthAzureService,
Expand Down

This file was deleted.

154 changes: 109 additions & 45 deletions backend/src/modules/azure/services/auth.azure.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import axios from 'axios';
import jwt_decode from 'jwt-decode';
import isEmpty from 'src/libs/utils/isEmpty';
import { GetTokenAuthService } from 'src/modules/auth/interfaces/services/get-token.auth.service.interface';
import * as AuthType from 'src/modules/auth/interfaces/types';
import * as StorageType from 'src/modules/storage/interfaces/types';
import { signIn } from 'src/modules/auth/shared/login.auth';
import { CreateUserService } from 'src/modules/users/interfaces/services/create.user.service.interface';
import { GetUserService } from 'src/modules/users/interfaces/services/get.user.service.interface';
import * as UserType from 'src/modules/users/interfaces/types';
import { AuthAzureService } from '../interfaces/services/auth.azure.service.interface';
import { CronAzureService } from '../interfaces/services/cron.azure.service.interface';
import { TYPES } from '../interfaces/types';
import * as CommunicationsType from 'src/modules/communication/interfaces/types';
import { CommunicationServiceInterface } from 'src/modules/communication/interfaces/slack-communication.service.interface';
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';
import { createHash } from 'node:crypto';
import User from 'src/modules/users/entities/user.schema';
import { StorageServiceInterface } from 'src/modules/storage/interfaces/services/storage.service';
import { UpdateUserService } from 'src/modules/users/interfaces/services/update.user.service.interface';

type AzureUserFound = {
mail?: string;
displayName?: string;
userPrincipalName?: string;
id: string;
mail: string;
displayName: string;
userPrincipalName: string;
createdDateTime: Date;
};

type AzureDecodedUser = {
Expand All @@ -30,18 +36,48 @@ type AzureDecodedUser = {

@Injectable()
export default class AuthAzureServiceImpl implements AuthAzureService {
private graphClient: Client;
private authCredentials: { accessToken: string; expiresOn: Date };

constructor(
@Inject(UserType.TYPES.services.CreateUserService)
private readonly createUserService: CreateUserService,
@Inject(UserType.TYPES.services.GetUserService)
private readonly getUserService: GetUserService,
@Inject(AuthType.TYPES.services.UpdateUserService)
private readonly updateUserService: UpdateUserService,
@Inject(AuthType.TYPES.services.GetTokenAuthService)
private readonly getTokenService: GetTokenAuthService,
@Inject(TYPES.services.CronAzureService)
private readonly cronAzureService: CronAzureService,
@Inject(CommunicationsType.TYPES.services.SlackCommunicationService)
private slackCommunicationService: CommunicationServiceInterface
) {}
private readonly configService: ConfigService,
@Inject(StorageType.TYPES.services.StorageService)
private readonly storageService: StorageServiceInterface
) {
const confidentialClient = new ConfidentialClientApplication({
auth: {
clientId: configService.get(AZURE_CLIENT_ID),
clientSecret: configService.get(AZURE_CLIENT_SECRET),
authority: configService.get(AZURE_AUTHORITY)
}
});

this.graphClient = Client.init({
fetchOptions: {
headers: { ConsistencyLevel: 'eventual' }
},
authProvider: async (done) => {
if (this.authCredentials?.expiresOn <= new Date()) {
return done(null, this.authCredentials.accessToken);
}

const { accessToken, expiresOn } = await confidentialClient.acquireTokenByClientCredential({
scopes: ['https://graph.microsoft.com/.default']
});

this.authCredentials = { accessToken, expiresOn };
done(null, accessToken);
}
});
}

async loginOrRegisterAzureToken(azureToken: string) {
const { unique_name, email, name, given_name, family_name } = <AzureDecodedUser>(
Expand All @@ -54,56 +90,84 @@ export default class AuthAzureServiceImpl implements AuthAzureService {

const emailOrUniqueName = email ?? unique_name;

const userExists = await this.checkUserExistsInActiveDirectory(emailOrUniqueName);
const userFromAzure = await this.getUserFromAzure(emailOrUniqueName);

if (!userExists || isEmpty(userFromAzure)) return null;
if (!userFromAzure) return null;

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

if (user) return signIn(user, this.getTokenService, 'azure');
let userToAuthenticate: User;

const createdUser = await this.createUserService.create({
email: emailOrUniqueName,
firstName,
lastName,
providerAccountCreatedAt: userFromAzure.value[0].createdDateTime
});
if (user) {
userToAuthenticate = user;
} else {
const createdUser = await this.createUserService.create({
email: emailOrUniqueName,
firstName,
lastName,
providerAccountCreatedAt: userFromAzure.createdDateTime
});

//this.slackCommunicationService.executeAddUserMainChannel({ email: emailOrUniqueName });
if (!createdUser) return null;

if (!createdUser) return null;
userToAuthenticate = createdUser;
}

return signIn(createdUser, this.getTokenService, 'azure');
}
const avatarUrl = await this.getUserPhoto(userToAuthenticate);

getGraphQueryUrl(email: string) {
return `https://graph.microsoft.com/v1.0/users?$search="mail:${email}" OR "displayName:${email}" OR "userPrincipalName:${email}"&$orderbydisplayName&$select=displayName,mail,userPrincipalName,createdDateTime`;
}
if (avatarUrl) {
await this.updateUserService.updateUserAvatar(userToAuthenticate._id, avatarUrl);

async getUserFromAzure(email: string) {
const queryUrl = this.getGraphQueryUrl(email);
userToAuthenticate.avatar = avatarUrl;
}

const { data } = await axios.get(queryUrl, {
headers: {
Authorization: `Bearer ${await this.cronAzureService.getAzureAccessToken()}`,
ConsistencyLevel: 'eventual'
}
});
return signIn(userToAuthenticate, this.getTokenService, 'azure');
}

async getUserFromAzure(email: string): Promise<AzureUserFound | undefined> {
const { value } = await this.graphClient
.api('/users')
.select(['id', 'displayName', 'mail', 'userPrincipalName', 'createdDateTime'])
.search(`"mail:${email}" OR "displayName:${email}" OR "userPrincipalName:${email}"`)
.orderby('displayName')
.get();

return data;
return value[0];
}

async checkUserExistsInActiveDirectory(email: string) {
const data = await this.getUserFromAzure(email);

const user = data.value.find(
(userFound: AzureUserFound) =>
userFound.mail?.toLowerCase() === email.toLowerCase() ||
userFound.displayName?.toLowerCase() === email.toLowerCase() ||
userFound.userPrincipalName?.toLowerCase() === email.toLowerCase()
);
return !isEmpty(data);
}

private async getUserPhoto(user: User) {
const { email, avatar } = user;
const azureUser = await this.getUserFromAzure(email);

if (!azureUser) return '';

try {
const blob = await this.graphClient.api(`/users/${azureUser.id}/photo/$value`).get();

const buffer = Buffer.from(await blob.arrayBuffer());
const hash = createHash('md5').update(buffer).digest('hex');

if (avatar) {
const avatarHash = avatar.split('/').pop().split('.').shift();

if (avatarHash === hash) return;

await this.storageService.deleteFile(avatar);
}

return !isEmpty(user);
return this.storageService.uploadFile(hash, {
buffer,
mimetype: blob.type,
originalname: `${hash}.${blob.type.split('/').pop()}`
});
} catch (ex) {
return '';
}
}
}
34 changes: 0 additions & 34 deletions backend/src/modules/azure/services/cron.azure.service.ts

This file was deleted.

10 changes: 10 additions & 0 deletions backend/src/modules/storage/interfaces/services/storage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { BlobDeleteResponse } from '@azure/storage-blob';

export interface StorageServiceInterface {
uploadFile(
fileName: string,
file: { originalname: string; buffer: Buffer; mimetype: string },
containerName?: string
): Promise<string>;
deleteFile(fileUrl: string, containerName?: string): Promise<void | BlobDeleteResponse>;
}
11 changes: 11 additions & 0 deletions backend/src/modules/storage/interfaces/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const TYPES = {
services: {
StorageService: 'StorageService'
}
};

export enum ContainerNameEnum {
SPLIT_IMAGES = 'split-images'
}

export type ContainerName = `${ContainerNameEnum}`;
Loading