From 1dbc0bf6c203a62a93e937ebff616884db5dbe7b Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Tue, 21 Oct 2025 10:50:11 -0400 Subject: [PATCH] refactor(auth): add functionality to complete auth cycle, utilizer user from request. --- docs/swagger/swagger-spec.json | 58 +++++++++++ .../controllers/auth.controller.ts | 29 ++++-- .../strategies/authentik.strategy.ts | 96 ++++++++++--------- .../controllers/employees.controller.ts | 17 ++-- src/modules/shifts/helpers/shifts.helpers.ts | 1 + .../shifts/services/shifts-command.service.ts | 11 ++- src/modules/shifts/utils/shifts.utils.ts | 4 +- .../services/abstract-user.service.ts | 12 ++- 8 files changed, 159 insertions(+), 69 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index d478a65..4f6c274 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -221,6 +221,46 @@ ] } }, + "/employees/profile/{email}": { + "get": { + "operationId": "EmployeesController_findOneProfile", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "description": "Identifier of the employee", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Employee profile found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmployeeProfileItemDto" + } + } + } + }, + "400": { + "description": "Employee profile not found" + } + }, + "security": [ + { + "access-token": [] + } + ], + "summary": "Find employee profile", + "tags": [ + "Employees" + ] + } + }, "/timesheets": { "get": { "operationId": "TimesheetsController_getPeriodByQuery", @@ -680,6 +720,20 @@ ] } }, + "/auth/me": { + "get": { + "operationId": "AuthController_getProfile", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, "/oauth-sessions": { "post": { "operationId": "OauthSessionsController_create", @@ -1463,6 +1517,10 @@ "first_work_day" ] }, + "EmployeeProfileItemDto": { + "type": "object", + "properties": {} + }, "CreateWeekShiftsDto": { "type": "object", "properties": {} diff --git a/src/modules/authentication/controllers/auth.controller.ts b/src/modules/authentication/controllers/auth.controller.ts index d3fbf12..248c4d1 100644 --- a/src/modules/authentication/controllers/auth.controller.ts +++ b/src/modules/authentication/controllers/auth.controller.ts @@ -1,17 +1,26 @@ -import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; +import { Controller, Get, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common'; import { OIDCLoginGuard } from '../guards/authentik-auth.guard'; import { Request, Response } from 'express'; @Controller('auth') export class AuthController { - - @UseGuards(OIDCLoginGuard) - @Get('/v1/login') - login() {} - @Get('/callback') - @UseGuards(OIDCLoginGuard) - loginCallback(@Req() req: Request, @Res() res: Response) { - res.redirect('http://localhost:9000/#/login-success'); - } + @UseGuards(OIDCLoginGuard) + @Get('/v1/login') + login() { } + + @Get('/callback') + @UseGuards(OIDCLoginGuard) + loginCallback(@Req() req: Request, @Res() res: Response) { + res.redirect('http://localhost:9000/#/login-success'); + } + + @Get('/me') + getProfile(@Req() req: Request) { + if (!req.user) { + throw new UnauthorizedException('Not logged in'); + } + return req.user; + } + } diff --git a/src/modules/authentication/strategies/authentik.strategy.ts b/src/modules/authentication/strategies/authentik.strategy.ts index 492028a..36115e1 100644 --- a/src/modules/authentication/strategies/authentik.strategy.ts +++ b/src/modules/authentication/strategies/authentik.strategy.ts @@ -3,53 +3,63 @@ import { Strategy as OIDCStrategy, Profile, VerifyCallback } from 'passport-open import { PassportStrategy } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; import { AuthentikAuthService } from '../services/authentik-auth.service'; +import { ValidationError } from 'class-validator'; export interface AuthentikPayload { - iss: string; // Issuer - sub: string; // Subject (user ID) - aud: string; // Audience (client ID) - exp: number; // Expiration time (Unix) - iat: number; // Issued at time (Unix) - auth_time: number; // Time of authentication (Unix) - acr?: string; // Auth Context Class Reference - amr?: string[]; // Auth Method References (e.g., ['pwd']) - email: string; - email_verified: boolean; - name?: string; - given_name?: string; - preferred_username?: string; - nickname?: string; - groups?: string[]; + iss: string; // Issuer + sub: string; // Subject (user ID) + aud: string; // Audience (client ID) + exp: number; // Expiration time (Unix) + iat: number; // Issued at time (Unix) + auth_time: number; // Time of authentication (Unix) + acr?: string; // Auth Context Class Reference + amr?: string[]; // Auth Method References (e.g., ['pwd']) + email: string; + email_verified: boolean; + name?: string; + given_name?: string; + preferred_username?: string; + nickname?: string; + groups?: string[]; } @Injectable() export class AuthentikStrategy extends PassportStrategy(OIDCStrategy, 'openidconnect', 8) { - constructor(private authentikAuthService: AuthentikAuthService) { - super({ - issuer: process.env.AUTHENTIK_ISSUER || "ISSUER_MISSING", - clientID: process.env.AUTHENTIK_CLIENT_ID || 'CLIENT_ID_MISSING', - clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'CLIENT_SECRET_MISSING', - callbackURL: process.env.AUTHENTIK_CALLBACK_URL || 'CALLBACK_URL_MISSING', - authorizationURL: process.env.AUTHENTIK_AUTH_URL || 'AUTH_URL_MISSING', - tokenURL: process.env.AUTHENTIK_TOKEN_URL || 'TOKEN_URL_MISSING', - userInfoURL: process.env.AUTHENTIK_USERINFO_URL || 'USERINFO_URL_MISSING', - scope: ['openid', 'email', 'profile', 'offline_access'], - },); - } - - async validate( - issuer: string, - profile: Profile, - _context: any, - idToken: string, - accessToken: string, - refreshToken: string, - params: any, - cb: VerifyCallback, - ): Promise { - - // saving all info from validate() into NestJS session under 'user' - /* TODO: Will need to add authorization logic with Prisma here later */ - return cb(null, { issuer, ...profile, idToken, accessToken, refreshToken, ...params }); - } + constructor(private authentikAuthService: AuthentikAuthService) { + super({ + issuer: process.env.AUTHENTIK_ISSUER || "ISSUER_MISSING", + clientID: process.env.AUTHENTIK_CLIENT_ID || 'CLIENT_ID_MISSING', + clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'CLIENT_SECRET_MISSING', + callbackURL: process.env.AUTHENTIK_CALLBACK_URL || 'CALLBACK_URL_MISSING', + authorizationURL: process.env.AUTHENTIK_AUTH_URL || 'AUTH_URL_MISSING', + tokenURL: process.env.AUTHENTIK_TOKEN_URL || 'TOKEN_URL_MISSING', + userInfoURL: process.env.AUTHENTIK_USERINFO_URL || 'USERINFO_URL_MISSING', + scope: ['openid', 'email', 'profile', 'offline_access'], + },); + } + + async validate( + _issuer: string, + profile: Profile, + _context: any, + _idToken: string, + _accessToken: string, + _refreshToken: string, + _params: any, + cb: VerifyCallback, + ): Promise { + try { + const email = profile.emails?.[0]?.value; + console.log('email: ', email); + if (!email) return cb(new Error('Missing email in OIDC profile'), false); + + const user = await this.authentikAuthService.validateUser(email); + console.log('user: ', user); + if (!user) return cb(new Error('User not found'), false); + + return cb(null, user); + } catch (err) { + return cb(err, false); + } + } } diff --git a/src/modules/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index 2026a28..e647547 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -6,6 +6,7 @@ import { RolesAllowed } from '../../../common/decorators/roles.decorators'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EmployeeListItemDto } from '../dtos/list-employee.dto'; import { EmployeesArchivalService } from '../services/employees-archival.service'; +import { EmployeeProfileItemDto } from 'src/modules/employees/dtos/profil-employee.dto'; @ApiTags('Employees') @ApiBearerAuth('access-token') @@ -76,14 +77,14 @@ export class EmployeesController { // return this.employeesService.findOne(email); // } - // @Get('profile/:email') - // @ApiOperation({summary: 'Find employee profile' }) - // @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' }) - // @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto }) - // @ApiResponse({ status: 400, description: 'Employee profile not found' }) - // findOneProfile(@Param('email') email: string): Promise { - // return this.employeesService.findOneProfile(email); - // } + @Get('profile/:email') + @ApiOperation({summary: 'Find employee profile' }) + @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' }) + @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto }) + @ApiResponse({ status: 400, description: 'Employee profile not found' }) + findOneProfile(@Param('email') email: string): Promise { + return this.employeesService.findOneProfile(email); + } // @Delete(':email') // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR ) diff --git a/src/modules/shifts/helpers/shifts.helpers.ts b/src/modules/shifts/helpers/shifts.helpers.ts index c5da538..ec8e140 100644 --- a/src/modules/shifts/helpers/shifts.helpers.ts +++ b/src/modules/shifts/helpers/shifts.helpers.ts @@ -30,6 +30,7 @@ export class ShiftsHelpersService { async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) { const start_of_week = weekStartSunday(date_only); + console.log('start of week: ', start_of_week); return tx.timesheets.findUnique({ where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, select: { id: true }, diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 06eeece..96a2408 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -164,7 +164,7 @@ export class ShiftsCommandService extends BaseApprovalService { email: string, date_iso: string, dto: UpsertShiftDto, - ): Promise<{ day: DayShiftResponse[]; }>{ + ){ return this.prisma.$transaction(async (tx) => { const date_only = toDateOnly(date_iso); //converts to Date format const employee_id = await this.emailResolver.findIdByEmail(email); @@ -174,6 +174,11 @@ export class ShiftsCommandService extends BaseApprovalService { const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift'); const bank_code_id = await this.typeResolver.findByType(norm_shift.type); + console.log('timesheet_id: ', timesheet.id ); + console.log('date: ', date_only); + console.log('bank code id: ', bank_code_id.id); + console.log('normalized old shift: ', norm_shift); + const existing = await this.helpersService.findExactOldShift(tx, { timesheet_id: timesheet.id, date_only, @@ -184,9 +189,7 @@ export class ShiftsCommandService extends BaseApprovalService { await tx.shifts.delete({ where: { id: existing.id } }); - await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); - const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only); - return { day: await this.helpersService.mapDay(fresh_shift)}; + // await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only); }); } } diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index 5262850..38935de 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -38,10 +38,10 @@ export function normalizeShiftPayload(payload: { const asLocalDateOn = (input: string): Date => { // HH:mm ? const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim()); - if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0); + if (hm) return new Date(Date.UTC(1970, 0, 1, Number(hm[1]), Number(hm[2]))); const iso = new Date(input); if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`); - return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds()); + return new Date(Date.UTC(1970, 0, 1, iso.getHours(), iso.getMinutes(), iso.getSeconds())); }; const start_time = asLocalDateOn(payload.start_time); diff --git a/src/modules/users-management/services/abstract-user.service.ts b/src/modules/users-management/services/abstract-user.service.ts index 51fd719..4e0da2e 100644 --- a/src/modules/users-management/services/abstract-user.service.ts +++ b/src/modules/users-management/services/abstract-user.service.ts @@ -18,12 +18,20 @@ export abstract class AbstractUserService { return user; } - async findOneByEmail( email: string ): Promise { + async findOneByEmail( email: string ): Promise> { const user = await this.prisma.users.findUnique({ where: { email } }); if (!user) { throw new NotFoundException(`No user with email #${email} exists`); } - return user; + + const clean_user = { + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + role: user.role, + } + + return clean_user; } async remove(id: string): Promise {