mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
refactor( iam ): migrate session, logout, and guards to IAM
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { PrismaService } from '../prisma.service';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,7 +10,7 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
|||||||
private readonly inactivityMinutes: number;
|
private readonly inactivityMinutes: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
|
||||||
@@ -18,29 +19,25 @@ export class SessionActivityInterceptor implements NestInterceptor {
|
|||||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const response = context.switchToHttp().getResponse();
|
const response = context.switchToHttp().getResponse();
|
||||||
const user = request.user;
|
const sessionId: string | undefined = request.user?.sessionId;
|
||||||
|
|
||||||
if (user?.id) {
|
if (sessionId) {
|
||||||
const session = await this.prisma.session.findFirst({
|
const rows = await this.dataSource.query<Array<{ expiry_time: Date }>>(
|
||||||
where: { userId: user.id },
|
`SELECT expiry_time FROM iam.sessions WHERE id = $1 AND status = 'ACTIVE' LIMIT 1`,
|
||||||
orderBy: { lastActivityAt: 'desc' },
|
[sessionId],
|
||||||
});
|
);
|
||||||
|
|
||||||
if (session) {
|
if (rows.length) {
|
||||||
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
|
const minutesLeft = (rows[0].expiry_time.getTime() - Date.now()) / 60000;
|
||||||
|
if (minutesLeft < this.inactivityMinutes * 0.2) {
|
||||||
if (inactiveMinutes > this.inactivityMinutes) {
|
response.setHeader('X-Session-Expiry-Warning', Math.floor(minutesLeft).toString());
|
||||||
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);
|
// Extend session on every authenticated request
|
||||||
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
|
await this.dataSource.query(
|
||||||
|
`UPDATE iam.sessions SET expiry_time = NOW() + ($1 * INTERVAL '1 minute') WHERE id = $2 AND status = 'ACTIVE'`,
|
||||||
await this.prisma.session.update({
|
[this.inactivityMinutes, sessionId],
|
||||||
where: { id: session.id },
|
);
|
||||||
data: { lastActivityAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { ROLES_KEY } from './roles.decorator';
|
import { ROLES_KEY } from './roles.decorator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -8,12 +7,15 @@ export class RolesGuard implements CanActivate {
|
|||||||
constructor(private reflector: Reflector) {}
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (!requiredRoles) return true;
|
if (!requiredRoles) return true;
|
||||||
const { user } = context.switchToHttp().getRequest();
|
const { user } = context.switchToHttp().getRequest();
|
||||||
return requiredRoles.some((role) => user?.role === role);
|
// Support IAM roles array [{key, id}][] and legacy role string
|
||||||
|
return requiredRoles.some(
|
||||||
|
(role) => user?.roles?.some((r: { key: string }) => r.key === role) || user?.role === role,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 200, description: 'Logout successful' })
|
@ApiResponse({ status: 200, description: 'Logout successful' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
logout(@Request() req: any) {
|
logout(@Request() req: any) {
|
||||||
const userId = req.user?.id ?? req.user?.userId;
|
if (!req.user?.id) throw new UnauthorizedException('User not authenticated');
|
||||||
if (!userId) throw new UnauthorizedException('User not authenticated');
|
return this.passengerAuthService.logout(req.user, req);
|
||||||
return this.service.logout(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PassengerAuthService } from './passenger-auth.service';
|
import { PassengerAuthService } from './passenger-auth.service';
|
||||||
import { JwtStrategy } from '../../common/jwt.strategy';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
|
||||||
PassportModule,
|
|
||||||
JwtModule.registerAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (c: ConfigService) => ({
|
|
||||||
secret: c.get('JWT_SECRET'),
|
|
||||||
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, PassengerAuthService, JwtStrategy],
|
providers: [AuthService, PassengerAuthService],
|
||||||
exports: [JwtModule],
|
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,75 +1,17 @@
|
|||||||
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
import { RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private prisma: PrismaService, private jwt: JwtService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto) {
|
|
||||||
const exists = await this.prisma.user.findFirst({
|
|
||||||
where: { OR: [{ email: dto.email }, { phone: dto.phone }] },
|
|
||||||
});
|
|
||||||
if (exists) throw new ConflictException('Email or phone already registered');
|
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
|
||||||
const user = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
fullName: dto.fullName,
|
|
||||||
email: dto.email,
|
|
||||||
phone: dto.phone,
|
|
||||||
passwordHash,
|
|
||||||
nationality: dto.nationality,
|
|
||||||
nationalId: dto.nationalId,
|
|
||||||
passportNumber: dto.passportNumber
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
|
||||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
|
||||||
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
|
||||||
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
|
||||||
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
|
|
||||||
return await this.signToken(user.id, user.email, user.role, passenger.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(dto: LoginDto) {
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { email: dto.email },
|
|
||||||
include: { passenger: true, agent: true },
|
|
||||||
});
|
|
||||||
if (!user) throw new UnauthorizedException('Invalid credentials');
|
|
||||||
|
|
||||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
||||||
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
failedLoginAttempts: { increment: 1 },
|
|
||||||
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { failedLoginAttempts: 0, lockedUntil: null }
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
|
|
||||||
return await this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async requestOtp(dto: RequestOtpDto) {
|
async requestOtp(dto: RequestOtpDto) {
|
||||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
await this.prisma.otpCode.create({
|
await this.prisma.otpCode.create({
|
||||||
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
|
data: { email: dto.email, code, purpose: dto.purpose, expiresAt },
|
||||||
});
|
});
|
||||||
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
|
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
|
||||||
return { sent: true, expiresIn: 600 };
|
return { sent: true, expiresIn: 600 };
|
||||||
@@ -78,7 +20,7 @@ export class AuthService {
|
|||||||
async verifyOtp(dto: VerifyOtpDto) {
|
async verifyOtp(dto: VerifyOtpDto) {
|
||||||
const otp = await this.prisma.otpCode.findFirst({
|
const otp = await this.prisma.otpCode.findFirst({
|
||||||
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
|
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
if (!otp) throw new BadRequestException('Invalid or expired OTP');
|
if (!otp) throw new BadRequestException('Invalid or expired OTP');
|
||||||
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
|
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
|
||||||
@@ -91,122 +33,54 @@ export class AuthService {
|
|||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
await this.prisma.passwordResetToken.create({
|
await this.prisma.passwordResetToken.create({
|
||||||
data: { userId: user.id, token, expiresAt }
|
data: { userId: user.id, token, expiresAt },
|
||||||
});
|
});
|
||||||
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
|
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
|
||||||
return { sent: true };
|
return { sent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(dto: ResetPasswordDto) {
|
async resetPassword(dto: ResetPasswordDto) {
|
||||||
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
const resetToken = await this.prisma.passwordResetToken.findUnique({ where: { token: dto.token } });
|
||||||
where: { token: dto.token }
|
|
||||||
});
|
|
||||||
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
||||||
throw new BadRequestException('Invalid or expired reset token');
|
throw new BadRequestException('Invalid or expired reset token');
|
||||||
}
|
}
|
||||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
await this.prisma.passwordResetToken.update({ where: { id: resetToken.id }, data: { used: true } });
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: resetToken.userId },
|
|
||||||
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
|
|
||||||
});
|
|
||||||
await this.prisma.passwordResetToken.update({
|
|
||||||
where: { id: resetToken.id },
|
|
||||||
data: { used: true }
|
|
||||||
});
|
|
||||||
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
|
|
||||||
return { reset: true };
|
return { reset: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
|
||||||
// Get the full user data to include fullName
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { id: true, email: true, fullName: true, role: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user: {
|
|
||||||
id: userId,
|
|
||||||
email,
|
|
||||||
fullName: user?.fullName || email,
|
|
||||||
role,
|
|
||||||
passengerId,
|
|
||||||
agentId
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: { userId, action, entityType, entityId, oldData, newData }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProfile(userId: string) {
|
async getProfile(userId: string) {
|
||||||
if (!userId) {
|
if (!userId) throw new UnauthorizedException('User ID not found in token');
|
||||||
throw new UnauthorizedException('User ID not found in token');
|
const user = await this.prisma.user.findFirst({
|
||||||
}
|
where: { OR: [{ id: userId }, { passenger: { iamUserId: userId } }] },
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
include: {
|
include: {
|
||||||
passenger: {
|
passenger: { include: { loyalty: true, wallet: true } },
|
||||||
include: {
|
|
||||||
loyalty: true,
|
|
||||||
wallet: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preferences: true,
|
preferences: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) throw new UnauthorizedException('User not found');
|
if (!user) throw new UnauthorizedException('User not found');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
iamUserId: user.passenger?.iamUserId,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
fullName: user.fullName,
|
fullName: user.fullName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
nationality: user.nationality,
|
nationality: user.nationality,
|
||||||
nationalityCode: user.nationalityCode,
|
|
||||||
nationalId: user.nationalId,
|
nationalId: user.nationalId,
|
||||||
passportNumber: user.passportNumber,
|
passportNumber: user.passportNumber,
|
||||||
faydaVerified: user.faydaVerified,
|
faydaVerified: user.faydaVerified,
|
||||||
faydaVerifiedAt: user.faydaVerifiedAt,
|
|
||||||
lastLoginAt: user.lastLoginAt,
|
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
passenger: user.passenger ? {
|
passenger: user.passenger ? {
|
||||||
id: user.passenger.id,
|
id: user.passenger.id,
|
||||||
preferredLanguage: user.passenger.preferredLanguage,
|
preferredLanguage: user.passenger.preferredLanguage,
|
||||||
loyalty: user.passenger.loyalty ? {
|
loyalty: user.passenger.loyalty
|
||||||
tier: user.passenger.loyalty.tier,
|
? { tier: user.passenger.loyalty.tier, pointsBalance: user.passenger.loyalty.pointsBalance, lifetimePoints: user.passenger.loyalty.lifetimePoints }
|
||||||
pointsBalance: user.passenger.loyalty.pointsBalance,
|
: null,
|
||||||
lifetimePoints: user.passenger.loyalty.lifetimePoints,
|
wallet: user.passenger.wallet
|
||||||
} : null,
|
? { balanceMinor: user.passenger.wallet.balanceMinor, currency: user.passenger.wallet.currency }
|
||||||
wallet: user.passenger.wallet ? {
|
: null,
|
||||||
balanceMinor: user.passenger.wallet.balanceMinor,
|
|
||||||
currency: user.passenger.wallet.currency,
|
|
||||||
} : null,
|
|
||||||
} : null,
|
} : null,
|
||||||
preferences: user.preferences,
|
preferences: user.preferences,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(userId: string) {
|
|
||||||
// Invalidate all active sessions for this user
|
|
||||||
await this.prisma.session.deleteMany({
|
|
||||||
where: { userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log the logout action
|
|
||||||
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Logged out successfully'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ export class PassengerAuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async logout(user: any, req: any) {
|
||||||
|
const iamAuthService = await this.resolveIamAuthService(req);
|
||||||
|
await iamAuthService.logout(user);
|
||||||
|
return { success: true, message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
private async compensateIamSignup(email: string): Promise<void> {
|
private async compensateIamSignup(email: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query(`DELETE FROM iam.sessions WHERE email = $1`, [email]);
|
await this.dataSource.query(`DELETE FROM iam.sessions WHERE email = $1`, [email]);
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ The API automatically detects:
|
|||||||
description: 'Invalid JWT token (only if token provided but invalid)'
|
description: 'Invalid JWT token (only if token provided but invalid)'
|
||||||
})
|
})
|
||||||
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
|
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.id ?? req.user?.userId;
|
||||||
return this.service.registerPassenger({ ...dto, userId });
|
return this.service.registerPassenger({ ...dto, userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user