mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Refactored the whole app based on the requirements shared
This commit is contained in:
9
apps/edr-passenger-api/src/common/i18n/i18n.module.ts
Normal file
9
apps/edr-passenger-api/src/common/i18n/i18n.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [I18nService],
|
||||
exports: [I18nService],
|
||||
})
|
||||
export class I18nModule {}
|
||||
49
apps/edr-passenger-api/src/common/i18n/i18n.service.spec.ts
Normal file
49
apps/edr-passenger-api/src/common/i18n/i18n.service.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
describe('I18nService', () => {
|
||||
let service: I18nService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [I18nService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<I18nService>(I18nService);
|
||||
});
|
||||
|
||||
it('should translate English keys', () => {
|
||||
expect(service.translate('common.welcome', 'en')).toBe('Welcome');
|
||||
expect(service.translate('booking.created', 'en')).toBe('Booking created successfully');
|
||||
});
|
||||
|
||||
it('should translate Amharic keys', () => {
|
||||
expect(service.translate('common.welcome', 'am')).toBe('እንኳን ደህና መጡ');
|
||||
});
|
||||
|
||||
it('should translate French keys', () => {
|
||||
expect(service.translate('common.welcome', 'fr')).toBe('Bienvenue');
|
||||
});
|
||||
|
||||
it('should translate Oromo keys', () => {
|
||||
expect(service.translate('common.welcome', 'om')).toBe('Baga nagaan dhuftan');
|
||||
});
|
||||
|
||||
it('should fallback to English for unsupported locale', () => {
|
||||
expect(service.translate('common.welcome', 'de')).toBe('Welcome');
|
||||
});
|
||||
|
||||
it('should return key if translation not found', () => {
|
||||
expect(service.translate('nonexistent.key', 'en')).toBe('nonexistent.key');
|
||||
});
|
||||
|
||||
it('should interpolate parameters', () => {
|
||||
const result = service.translate('common.welcome', 'en', { name: 'John' });
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return supported locales', () => {
|
||||
const locales = service.getSupportedLocales();
|
||||
expect(locales).toEqual(['en', 'am', 'fr', 'om']);
|
||||
});
|
||||
});
|
||||
63
apps/edr-passenger-api/src/common/i18n/i18n.service.ts
Normal file
63
apps/edr-passenger-api/src/common/i18n/i18n.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
type TranslationMap = Record<string, any>;
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
private translations: Map<string, TranslationMap> = new Map();
|
||||
private readonly supportedLocales = ['en', 'am', 'fr', 'om'];
|
||||
private readonly defaultLocale = 'en';
|
||||
|
||||
constructor() {
|
||||
this.loadTranslations();
|
||||
}
|
||||
|
||||
private loadTranslations() {
|
||||
for (const locale of this.supportedLocales) {
|
||||
const filePath = path.join(__dirname, 'translations', `${locale}.json`);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.translations.set(locale, JSON.parse(content));
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load translation file for locale: ${locale}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
translate(key: string, locale: string = this.defaultLocale, params?: Record<string, string>): string {
|
||||
const normalizedLocale = this.normalizeLocale(locale);
|
||||
const translations = this.translations.get(normalizedLocale) || this.translations.get(this.defaultLocale);
|
||||
|
||||
if (!translations) return key;
|
||||
|
||||
const keys = key.split('.');
|
||||
let value: any = translations;
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k];
|
||||
if (value === undefined) return key;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') return key;
|
||||
|
||||
if (params) {
|
||||
return Object.entries(params).reduce(
|
||||
(text, [param, val]) => text.replace(new RegExp(`{{${param}}}`, 'g'), val),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeLocale(locale: string): string {
|
||||
const normalized = locale.toLowerCase().split('-')[0];
|
||||
return this.supportedLocales.includes(normalized) ? normalized : this.defaultLocale;
|
||||
}
|
||||
|
||||
getSupportedLocales(): string[] {
|
||||
return this.supportedLocales;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { LOCALE_KEY } from './locale.middleware';
|
||||
|
||||
export const Locale = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request[LOCALE_KEY] || 'en';
|
||||
},
|
||||
);
|
||||
16
apps/edr-passenger-api/src/common/i18n/locale.middleware.ts
Normal file
16
apps/edr-passenger-api/src/common/i18n/locale.middleware.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
|
||||
export const LOCALE_KEY = 'locale';
|
||||
|
||||
@Injectable()
|
||||
export class LocaleMiddleware implements NestMiddleware {
|
||||
use(req: any, res: any, next: () => void) {
|
||||
const locale =
|
||||
req.query.lang as string ||
|
||||
req.headers['accept-language']?.split(',')[0]?.split('-')[0] ||
|
||||
'en';
|
||||
|
||||
req[LOCALE_KEY] = locale;
|
||||
next();
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/am.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/am.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"common": {
|
||||
"welcome": "እንኳን ደህና መጡ",
|
||||
"error": "ስህተት ተከስቷል",
|
||||
"success": "ተሳክቷል"
|
||||
},
|
||||
"booking": {
|
||||
"created": "ቦታ ማስያዝ በተሳካ ሁኔታ ተፈጥሯል",
|
||||
"notFound": "ቦታ ማስያዝ አልተገኘም",
|
||||
"cancelled": "ቦታ ማስያዝ ተሰርዟል",
|
||||
"confirmed": "ቦታ ማስያዝ ተረጋግጧል"
|
||||
},
|
||||
"payment": {
|
||||
"succeeded": "ክፍያ ተሳክቷል",
|
||||
"failed": "ክፍያ አልተሳካም",
|
||||
"pending": "ክፍያ በመጠባበቅ ላይ"
|
||||
},
|
||||
"ticket": {
|
||||
"issued": "ትኬት ተሰጥቷል",
|
||||
"validated": "ትኬት ተረጋግጧል",
|
||||
"alreadyValidated": "ትኬት ቀድሞውኑ ተረጋግጧል"
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/en.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/en.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"common": {
|
||||
"welcome": "Welcome",
|
||||
"error": "An error occurred",
|
||||
"success": "Success"
|
||||
},
|
||||
"booking": {
|
||||
"created": "Booking created successfully",
|
||||
"notFound": "Booking not found",
|
||||
"cancelled": "Booking cancelled",
|
||||
"confirmed": "Booking confirmed"
|
||||
},
|
||||
"payment": {
|
||||
"succeeded": "Payment successful",
|
||||
"failed": "Payment failed",
|
||||
"pending": "Payment pending"
|
||||
},
|
||||
"ticket": {
|
||||
"issued": "Ticket issued",
|
||||
"validated": "Ticket validated",
|
||||
"alreadyValidated": "Ticket already validated"
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/fr.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/fr.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"common": {
|
||||
"welcome": "Bienvenue",
|
||||
"error": "Une erreur s'est produite",
|
||||
"success": "Succès"
|
||||
},
|
||||
"booking": {
|
||||
"created": "Réservation créée avec succès",
|
||||
"notFound": "Réservation introuvable",
|
||||
"cancelled": "Réservation annulée",
|
||||
"confirmed": "Réservation confirmée"
|
||||
},
|
||||
"payment": {
|
||||
"succeeded": "Paiement réussi",
|
||||
"failed": "Échec du paiement",
|
||||
"pending": "Paiement en attente"
|
||||
},
|
||||
"ticket": {
|
||||
"issued": "Billet émis",
|
||||
"validated": "Billet validé",
|
||||
"alreadyValidated": "Billet déjà validé"
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/om.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/om.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"common": {
|
||||
"welcome": "Baga nagaan dhuftan",
|
||||
"error": "Dogongora uumame",
|
||||
"success": "Milkaa'ina"
|
||||
},
|
||||
"booking": {
|
||||
"created": "Bakka qabachuu milkaa'inaan uumame",
|
||||
"notFound": "Bakka qabachuu hin argamne",
|
||||
"cancelled": "Bakka qabachuu haqame",
|
||||
"confirmed": "Bakka qabachuu mirkaneeffame"
|
||||
},
|
||||
"payment": {
|
||||
"succeeded": "Kaffaltiin milkaa'e",
|
||||
"failed": "Kaffaltiin hin milkoofne",
|
||||
"pending": "Kaffaltiin eegaa jira"
|
||||
},
|
||||
"ticket": {
|
||||
"issued": "Tiikeetiin kenname",
|
||||
"validated": "Tiikeetiin mirkaneeffame",
|
||||
"alreadyValidated": "Tiikeetiin duraan mirkaneeffame"
|
||||
}
|
||||
}
|
||||
264
apps/edr-passenger-api/src/common/iam-adapter.spec.ts
Normal file
264
apps/edr-passenger-api/src/common/iam-adapter.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { IamGuard } from './iam-adapter';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
describe('IamGuard', () => {
|
||||
let guard: IamGuard;
|
||||
let httpService: HttpService;
|
||||
let configService: ConfigService;
|
||||
let reflector: Reflector;
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, string> = {
|
||||
IAM_API_URL: 'https://iam.test.com/api',
|
||||
IAM_ENABLED: 'true',
|
||||
IAM_API_KEY: 'test-api-key',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const mockHttpService = {
|
||||
post: jest.fn(),
|
||||
};
|
||||
|
||||
const mockReflector = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
IamGuard,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: HttpService, useValue: mockHttpService },
|
||||
{ provide: Reflector, useValue: mockReflector },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<IamGuard>(IamGuard);
|
||||
httpService = module.get<HttpService>(HttpService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockContext = (token?: string, roles?: string[]): ExecutionContext => {
|
||||
const request = {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
user: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
getHandler: () => ({}),
|
||||
} as ExecutionContext;
|
||||
};
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should allow access when IAM is disabled', async () => {
|
||||
mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED
|
||||
|
||||
const context = createMockContext();
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when no token provided', async () => {
|
||||
const context = createMockContext();
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should validate token and allow access', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['read', 'write'],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(null);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHttpService.post).toHaveBeenCalledWith(
|
||||
'https://iam.test.com/api/v1/auth/validate',
|
||||
{ token: 'valid-token' },
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-API-Key': 'test-api-key',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid token', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: false,
|
||||
error: 'Token expired',
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
|
||||
const context = createMockContext('invalid-token');
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check required roles', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'agent@test.com',
|
||||
roles: ['AGENT'],
|
||||
permissions: [],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should allow access when user has required role', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: [],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle HTTP errors gracefully', async () => {
|
||||
mockHttpService.post.mockReturnValue(
|
||||
throwError(() => new Error('Network error')),
|
||||
);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should attach user to request', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['read', 'write'],
|
||||
organizationId: 'org-456',
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(null);
|
||||
|
||||
const context = createMockContext('valid-token');
|
||||
await guard.canActivate(context);
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
expect(request.user).toEqual({
|
||||
userId: 'user-123',
|
||||
email: 'admin@test.com',
|
||||
roles: ['ADMIN'],
|
||||
permissions: ['read', 'write'],
|
||||
organizationId: 'org-456',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('token extraction', () => {
|
||||
it('should extract token from Bearer header', async () => {
|
||||
const mockValidationResponse = {
|
||||
data: {
|
||||
valid: true,
|
||||
payload: {
|
||||
sub: 'user-123',
|
||||
email: 'test@test.com',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
exp: Date.now() + 3600000,
|
||||
iat: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
|
||||
mockReflector.get.mockReturnValue(null);
|
||||
|
||||
const context = createMockContext('my-token-123');
|
||||
await guard.canActivate(context);
|
||||
|
||||
expect(mockHttpService.post).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ token: 'my-token-123' },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject malformed authorization header', async () => {
|
||||
const request = {
|
||||
headers: { authorization: 'InvalidFormat token' },
|
||||
};
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
getHandler: () => ({}),
|
||||
} as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
144
apps/edr-passenger-api/src/common/iam-adapter.ts
Normal file
144
apps/edr-passenger-api/src/common/iam-adapter.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* IAM Adapter for @tria-plc corporate identity integration
|
||||
*
|
||||
* This adapter wraps the corporate IAM guards and provides a bridge
|
||||
* between the corporate identity system and the EDR passenger API.
|
||||
*
|
||||
* For back-office roles (agent, supervisor, admin, staff), this guard
|
||||
* validates tokens against the corporate IAM service.
|
||||
*
|
||||
* For passenger-facing routes, the existing JWT guard is used.
|
||||
*/
|
||||
|
||||
export interface IamTokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
organizationId?: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface IamValidationResponse {
|
||||
valid: boolean;
|
||||
payload?: IamTokenPayload;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IamGuard implements CanActivate {
|
||||
private readonly iamApiUrl: string;
|
||||
private readonly iamEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
|
||||
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (!this.iamEnabled) {
|
||||
// IAM disabled - allow access (for development)
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No authentication token provided');
|
||||
}
|
||||
|
||||
const validation = await this.validateToken(token);
|
||||
|
||||
if (!validation.valid || !validation.payload) {
|
||||
throw new UnauthorizedException(validation.error || 'Invalid token');
|
||||
}
|
||||
|
||||
// Check required roles
|
||||
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: validation.payload.sub,
|
||||
email: validation.payload.email,
|
||||
roles: validation.payload.roles,
|
||||
permissions: validation.payload.permissions,
|
||||
organizationId: validation.payload.organizationId,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractToken(request: any): string | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) return null;
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
private async validateToken(token: string): Promise<IamValidationResponse> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<IamValidationResponse>(
|
||||
`${this.iamApiUrl}/v1/auth/validate`,
|
||||
{ token },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
return {
|
||||
valid: false,
|
||||
error: err instanceof Error ? err.message : 'Token validation failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to mark routes as requiring IAM authentication
|
||||
*/
|
||||
export const UseIamAuth = () => {
|
||||
// This is a marker decorator that can be used with @UseGuards(IamGuard)
|
||||
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
|
||||
// Marker only - actual guard is applied via @UseGuards
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorator to specify required roles for IAM-protected routes
|
||||
*/
|
||||
export const IamRoles = (...roles: string[]) => {
|
||||
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
|
||||
if (descriptor) {
|
||||
Reflect.defineMetadata('roles', roles, descriptor.value);
|
||||
}
|
||||
};
|
||||
};
|
||||
11
apps/edr-passenger-api/src/common/iam.module.ts
Normal file
11
apps/edr-passenger-api/src/common/iam.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { IamGuard } from './iam-adapter';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [HttpModule.register({ timeout: 5000 })],
|
||||
providers: [IamGuard],
|
||||
exports: [IamGuard],
|
||||
})
|
||||
export class IamModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { PrismaService } from '../prisma.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class SessionActivityInterceptor implements NestInterceptor {
|
||||
private readonly inactivityMinutes: number;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
||||
}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const user = request.user;
|
||||
|
||||
if (user?.userId) {
|
||||
const session = await this.prisma.session.findFirst({
|
||||
where: { userId: user.userId },
|
||||
orderBy: { lastActivityAt: 'desc' },
|
||||
});
|
||||
|
||||
if (session) {
|
||||
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
|
||||
|
||||
if (inactiveMinutes > this.inactivityMinutes) {
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
throw new UnauthorizedException('Session expired due to inactivity');
|
||||
}
|
||||
|
||||
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes);
|
||||
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { lastActivityAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next.handle().pipe(tap(() => {}));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { SessionActivityInterceptor } from './interceptors/session-activity.interceptor';
|
||||
|
||||
@Global()
|
||||
@Module({ providers: [PrismaService], exports: [PrismaService] })
|
||||
@Module({ providers: [PrismaService, SessionActivityInterceptor], exports: [PrismaService, SessionActivityInterceptor] })
|
||||
export class PrismaModule {}
|
||||
|
||||
5
apps/edr-passenger-api/src/common/roles.decorator.ts
Normal file
5
apps/edr-passenger-api/src/common/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
19
apps/edr-passenger-api/src/common/roles.guard.ts
Normal file
19
apps/edr-passenger-api/src/common/roles.guard.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) return true;
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user