Skip to content

Commit

Permalink
feat(auth-azure): upload and set user photo on login (#1132)
Browse files Browse the repository at this point in the history
  • Loading branch information
Rui Silva authored Feb 21, 2023
1 parent b559d5d commit c48b625
Show file tree
Hide file tree
Showing 30 changed files with 871 additions and 173 deletions.
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

0 comments on commit c48b625

Please sign in to comment.