552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
|
import { CreateUserData, UserRole, KeycloakUser, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model'; // SUPPRIMER import type
|
|
import { LoginDto, TokenResponse, User } from '../models/hub-user.model';
|
|
|
|
// ===== SERVICE PRINCIPAL =====
|
|
@Injectable()
|
|
export class HubUsersService {
|
|
private readonly logger = new Logger(HubUsersService.name);
|
|
|
|
// Définir les rôles Hub autorisés comme constante
|
|
private readonly HUB_ROLES = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER];
|
|
|
|
constructor(private readonly keycloakApi: KeycloakApiService) {}
|
|
|
|
// === AUTHENTIFICATION UTILISATEUR ===
|
|
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
|
|
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
|
|
}
|
|
|
|
// ===== VALIDATION ET UTILITAIRES =====
|
|
private isValidHubRole(role: UserRole): role is UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER {
|
|
return this.HUB_ROLES.includes(role);
|
|
}
|
|
|
|
private validateHubRole(role: UserRole): void {
|
|
if (!this.isValidHubRole(role)) {
|
|
throw new BadRequestException(
|
|
`Invalid hub role: ${role}. Hub roles must be DCB_ADMIN or DCB_SUPPORT`
|
|
);
|
|
}
|
|
}
|
|
|
|
validateHubRoleFromString(role: string): UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER {
|
|
// Vérifier si le rôle est valide
|
|
if (!Object.values(UserRole).includes(role as UserRole)) {
|
|
throw new BadRequestException(`Invalid role: ${role}`);
|
|
}
|
|
|
|
const userRole = role as UserRole;
|
|
if (!this.isValidHubRole(userRole)) {
|
|
throw new BadRequestException(
|
|
`Invalid hub role: ${role}. Must be DCB_ADMIN or DCB_SUPPORT`
|
|
);
|
|
}
|
|
return userRole;
|
|
}
|
|
|
|
private async validateHubUserAccess(requesterId: string): Promise<void> {
|
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
|
const isHubAdmin = requesterRoles.some(role =>
|
|
this.HUB_ROLES.includes(role.name as UserRole)
|
|
);
|
|
|
|
if (!isHubAdmin) {
|
|
throw new ForbiddenException('Only hub administrators can manage hub users');
|
|
}
|
|
}
|
|
|
|
private parseKeycloakAttribute(value: string[] | undefined): string | undefined {
|
|
return value?.[0];
|
|
}
|
|
|
|
private parseKeycloakTimestamp(value: string[] | undefined): number | undefined {
|
|
const strValue = this.parseKeycloakAttribute(value);
|
|
if (!strValue) return undefined;
|
|
|
|
const timestamp = parseInt(strValue);
|
|
if (!isNaN(timestamp)) return timestamp;
|
|
|
|
const date = new Date(strValue);
|
|
return isNaN(date.getTime()) ? undefined : date.getTime();
|
|
}
|
|
|
|
private mapToHubUser(user: KeycloakUser, roles: any[]): HubUser {
|
|
const hubRole = roles.find(role => this.isValidHubRole(role.name as UserRole));
|
|
|
|
if (!user.id) throw new Error('User ID is required');
|
|
if (!user.email) throw new Error('User email is required');
|
|
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
firstName: user.firstName || '',
|
|
lastName: user.lastName || '',
|
|
role: hubRole?.name as UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER,
|
|
enabled: user.enabled,
|
|
emailVerified: user.emailVerified,
|
|
createdBy: this.parseKeycloakAttribute(user.attributes?.createdBy) || 'unknown',
|
|
createdByUsername: this.parseKeycloakAttribute(user.attributes?.createdByUsername) || 'unknown',
|
|
createdTimestamp: user.createdTimestamp || Date.now(),
|
|
lastLogin: this.parseKeycloakTimestamp(user.attributes?.lastLogin),
|
|
userType: 'HUB',
|
|
attributes: user.attributes,
|
|
};
|
|
}
|
|
|
|
// ===== OPÉRATIONS CRUD =====
|
|
async createHubUser(creatorId: string, userData: CreateHubUserData): Promise<HubUser> {
|
|
this.logger.log(`Creating hub user: ${userData.username} with role: ${userData.role}`);
|
|
|
|
this.validateHubRole(userData.role);
|
|
await this.validateHubUserAccess(creatorId);
|
|
|
|
// Vérifier les doublons
|
|
this.checkDuplicateHubUser(userData)
|
|
|
|
const keycloakUserData: CreateUserData = {
|
|
username: userData.username,
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
password: userData.password,
|
|
enabled: userData.enabled ?? true,
|
|
emailVerified: userData.emailVerified ?? false,
|
|
merchantPartnerId: undefined,
|
|
clientRoles: [userData.role],
|
|
createdBy: creatorId,
|
|
initialStatus: 'PENDING_ACTIVATION',
|
|
};
|
|
|
|
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
|
|
const createdUser = await this.getHubUserById(userId, creatorId);
|
|
|
|
this.logger.log(`Hub user created successfully: ${userData.username} (ID: ${userId})`);
|
|
return createdUser;
|
|
}
|
|
|
|
private async checkDuplicateHubUser(merchantData: CreateHubUserData): Promise<void> {
|
|
const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.username);
|
|
if (existingUsers.length > 0) {
|
|
throw new BadRequestException(`Merchant partner with username ${merchantData.username} already exists`);
|
|
}
|
|
|
|
const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.email);
|
|
if (existingEmails.length > 0) {
|
|
throw new BadRequestException(`Merchant partner with email ${merchantData.email} already exists`);
|
|
}
|
|
}
|
|
|
|
// ===== RÉCUPÉRATION DE TOUS LES MERCHANTS (DCB_PARTNER) =====
|
|
async getAllMerchants(requesterId: string): Promise<HubUser[]> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
|
|
const allUsers = await this.keycloakApi.getAllUsers();
|
|
const merchants: HubUser[] = [];
|
|
|
|
for (const user of allUsers) {
|
|
if (!user.id) continue;
|
|
|
|
try {
|
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
|
const isMerchant = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
|
|
|
if (isMerchant) {
|
|
merchants.push(this.mapToHubUser(user, userRoles));
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn(`Could not process merchant ${user.id}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return merchants;
|
|
}
|
|
|
|
async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise<void> {
|
|
await this.validateHubAccess(requesterId);
|
|
|
|
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
|
|
|
|
// Suspendre le merchant et tous ses utilisateurs
|
|
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, {
|
|
merchantStatus: ['SUSPENDED'],
|
|
suspensionReason: [reason],
|
|
suspendedAt: [new Date().toISOString()],
|
|
});
|
|
|
|
// Suspendre tous les utilisateurs du merchant
|
|
await this.suspendAllMerchantUsers(merchantId, requesterId);
|
|
|
|
this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`);
|
|
}
|
|
|
|
async getMerchantPartnerById(merchantId: string, requesterId: string): Promise<HubUser> {
|
|
await this.validateMerchantAccess(requesterId, merchantId);
|
|
|
|
const merchantPartner = await this.findDcbPartnerByMerchantId(merchantId);
|
|
|
|
const merchantPartnerRoles = await this.keycloakApi.getUserClientRoles(merchantId);
|
|
|
|
return this.mapToHubUser(merchantPartner, merchantPartnerRoles);
|
|
}
|
|
|
|
// ===== STATISTIQUES =====
|
|
async getMerchantStats(requesterId: string): Promise<MerchantStats> {
|
|
await this.validateHubAccess(requesterId);
|
|
|
|
const allMerchants = await this.getAllMerchants(requesterId);
|
|
const allUsers = await this.keycloakApi.getAllUsers();
|
|
|
|
let totalUsers = 0;
|
|
for (const merchant of allMerchants) {
|
|
const merchantUsers = allUsers.filter(user =>
|
|
user.attributes?.merchantPartnerId?.[0] === merchant.id
|
|
);
|
|
totalUsers += merchantUsers.length;
|
|
}
|
|
|
|
return {
|
|
totalMerchants: allMerchants.length,
|
|
activeMerchants: allMerchants.filter(m => m.enabled).length,
|
|
suspendedMerchants: allMerchants.filter(m => !m.enabled).length,
|
|
pendingMerchants: allMerchants.filter(m => m.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION'
|
|
).length,
|
|
totalUsers,
|
|
};
|
|
}
|
|
|
|
private async validateHubAccess(requesterId: string): Promise<void> {
|
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
|
const isHubAdmin = requesterRoles.some(role =>
|
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
|
);
|
|
|
|
if (!isHubAdmin) {
|
|
throw new ForbiddenException('Only hub administrators can access this resource');
|
|
}
|
|
}
|
|
|
|
private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise<void> {
|
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
|
|
|
this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`);
|
|
this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`);
|
|
|
|
// Les admins Hub ont accès complet
|
|
const isHubAdmin = requesterRoles.some(role =>
|
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
|
);
|
|
|
|
if (isHubAdmin) {
|
|
this.logger.debug('Hub admin access granted');
|
|
return;
|
|
}
|
|
|
|
// CORRECTION: Service account est considéré comme admin hub
|
|
if (requesterId.includes('service-account')) {
|
|
this.logger.debug('Service account access granted');
|
|
return;
|
|
}
|
|
|
|
// Les DCB_PARTNER n'ont accès qu'à leur propre merchant
|
|
if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
|
|
if (merchantId && requesterId === merchantId) {
|
|
this.logger.debug('DCB_PARTNER access to own merchant granted');
|
|
return; // DCB_PARTNER accède à son propre merchant
|
|
}
|
|
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner');
|
|
}
|
|
|
|
// Les autres rôles merchants n'ont accès qu'à leur merchant
|
|
const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId);
|
|
if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) {
|
|
this.logger.debug('Merchant user access to own merchant granted');
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenException('Insufficient permissions to access this merchant');
|
|
}
|
|
|
|
private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise<void> {
|
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
|
|
|
// Seuls les admins Hub peuvent modifier les merchants
|
|
const isHubAdmin = requesterRoles.some(role =>
|
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
|
);
|
|
|
|
if (!isHubAdmin) {
|
|
throw new ForbiddenException('Only hub administrators can manage merchant partners');
|
|
}
|
|
}
|
|
|
|
private async findDcbPartnerByMerchantId(merchantId: string): Promise<KeycloakUser> {
|
|
const users = await this.keycloakApi.findUserByUsername(merchantId);
|
|
if (users.length === 0) {
|
|
throw new NotFoundException(`Merchant partner not found: ${merchantId}`);
|
|
}
|
|
|
|
const user = users[0];
|
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id!);
|
|
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
|
|
|
if (!isDcbPartner) {
|
|
throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise<void> {
|
|
const allUsers = await this.keycloakApi.getAllUsers();
|
|
const merchantUsers = allUsers.filter(user =>
|
|
user.attributes?.merchantPartnerId?.[0] === merchantId
|
|
);
|
|
|
|
for (const user of merchantUsers) {
|
|
if (user.id) {
|
|
try {
|
|
await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId);
|
|
} catch (error) {
|
|
this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async getHubUserById(userId: string, requesterId: string): Promise<HubUser> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
|
|
const [user, userRoles] = await Promise.all([
|
|
this.keycloakApi.getUserById(userId, requesterId),
|
|
this.keycloakApi.getUserClientRoles(userId)
|
|
]);
|
|
|
|
const isHubUser = userRoles.some(role => this.isValidHubRole(role.name as UserRole));
|
|
if (!isHubUser) {
|
|
throw new BadRequestException(`User ${userId} is not a hub user`);
|
|
}
|
|
|
|
return this.mapToHubUser(user, userRoles);
|
|
}
|
|
|
|
async getAllHubUsers(requesterId: string): Promise<HubUser[]> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
|
|
const allUsers = await this.keycloakApi.getAllUsers();
|
|
const hubUsers: HubUser[] = [];
|
|
|
|
for (const user of allUsers) {
|
|
if (!user.id) continue;
|
|
|
|
try {
|
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
|
const hubRole = userRoles.find(role => this.isValidHubRole(role.name as UserRole));
|
|
|
|
if (hubRole) {
|
|
hubUsers.push(this.mapToHubUser(user, userRoles));
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn(`Could not process user ${user.id}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return hubUsers;
|
|
}
|
|
|
|
async getHubUsersByRole(role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER, requesterId: string): Promise<HubUser[]> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
|
|
const allHubUsers = await this.getAllHubUsers(requesterId);
|
|
return allHubUsers.filter(user => user.role === role);
|
|
}
|
|
|
|
async updateHubUser(
|
|
userId: string,
|
|
updates: Partial<{
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
enabled: boolean;
|
|
}>,
|
|
requesterId: string
|
|
): Promise<HubUser> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
await this.getHubUserById(userId, requesterId);
|
|
|
|
await this.keycloakApi.updateUser(userId, updates, requesterId);
|
|
return this.getHubUserById(userId, requesterId);
|
|
}
|
|
|
|
async updateHubUserRole(
|
|
userId: string,
|
|
newRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT |UserRole.DCB_PARTNER,
|
|
requesterId: string
|
|
): Promise<HubUser> {
|
|
// Validation implicite via le typage
|
|
await this.validateHubUserAccess(requesterId);
|
|
|
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
|
const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN);
|
|
|
|
if (!isRequesterAdmin) {
|
|
throw new ForbiddenException('Only DCB_ADMIN can change user roles');
|
|
}
|
|
|
|
await this.getHubUserById(userId, requesterId);
|
|
await this.keycloakApi.setClientRoles(userId, [newRole]);
|
|
|
|
return this.getHubUserById(userId, requesterId);
|
|
}
|
|
|
|
async updateMerchantPartner(
|
|
merchantId: string,
|
|
updates: Partial<{
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
enabled: boolean;
|
|
}>,
|
|
requesterId: string
|
|
): Promise<HubUser> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
await this.getHubUserById(merchantId, requesterId);
|
|
|
|
await this.validateMerchantManagementPermissions(requesterId, merchantId);
|
|
|
|
return this.getMerchantPartnerById(merchantId, requesterId);
|
|
}
|
|
|
|
async deleteHubUser(userId: string, requesterId: string): Promise<void> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
await this.getHubUserById(userId, requesterId);
|
|
|
|
if (userId === requesterId) {
|
|
throw new BadRequestException('Cannot delete your own account');
|
|
}
|
|
|
|
if (await this.isLastAdmin(userId)) {
|
|
throw new BadRequestException('Cannot delete the last DCB_ADMIN user');
|
|
}
|
|
|
|
await this.keycloakApi.deleteUser(userId, requesterId);
|
|
this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`);
|
|
}
|
|
|
|
// ===== GESTION DES MOTS DE PASSE =====
|
|
async resetHubUserPassword(
|
|
userId: string,
|
|
newPassword: string,
|
|
temporary: boolean = true,
|
|
requesterId: string
|
|
): Promise<void> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
await this.getHubUserById(userId, requesterId);
|
|
|
|
await this.keycloakApi.resetUserPassword(userId, newPassword, temporary);
|
|
this.logger.log(`Password reset for hub user: ${userId}`);
|
|
}
|
|
|
|
async sendHubUserPasswordResetEmail(userId: string, requesterId: string): Promise<void> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
const user = await this.getHubUserById(userId, requesterId);
|
|
|
|
await this.keycloakApi.sendPasswordResetEmail(user.email);
|
|
this.logger.log(`Password reset email sent to hub user: ${user.email}`);
|
|
}
|
|
|
|
// ===== STATISTIQUES ET RAPPORTS =====
|
|
async getHubUsersStats(requesterId: string): Promise<HubUserStats> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
const allHubUsers = await this.getAllHubUsers(requesterId);
|
|
|
|
return {
|
|
totalAdmins: allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length,
|
|
totalSupport: allHubUsers.filter(user => user.role === UserRole.DCB_SUPPORT).length,
|
|
activeUsers: allHubUsers.filter(user => user.enabled).length,
|
|
inactiveUsers: allHubUsers.filter(user => !user.enabled).length,
|
|
pendingActivation: allHubUsers.filter(user =>
|
|
user.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION'
|
|
).length,
|
|
};
|
|
}
|
|
|
|
async getHubUserActivity(requesterId: string): Promise<HubUserActivity[]> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
const allHubUsers = await this.getAllHubUsers(requesterId);
|
|
|
|
return allHubUsers.map(user => ({
|
|
user,
|
|
lastLogin: user.lastLogin ? new Date(user.lastLogin) : undefined
|
|
}));
|
|
}
|
|
|
|
async getActiveHubSessions(requesterId: string): Promise<{ userId: string; username: string; lastAccess: Date }[]> {
|
|
const activity = await this.getHubUserActivity(requesterId);
|
|
|
|
return activity
|
|
.filter(item => item.lastLogin)
|
|
.map(item => ({
|
|
userId: item.user.id,
|
|
username: item.user.username,
|
|
lastAccess: item.lastLogin!
|
|
}));
|
|
}
|
|
|
|
// ===== SANTÉ ET VALIDATIONS =====
|
|
async checkHubUsersHealth(requesterId: string): Promise<HubHealthStatus> {
|
|
await this.validateHubUserAccess(requesterId);
|
|
const stats = await this.getHubUsersStats(requesterId);
|
|
const issues: string[] = [];
|
|
|
|
if (stats.totalAdmins === 0) {
|
|
issues.push('No DCB_ADMIN users found - system is vulnerable');
|
|
}
|
|
if (stats.totalAdmins === 1) {
|
|
issues.push('Only one DCB_ADMIN user exists - consider creating backup administrators');
|
|
}
|
|
if (stats.inactiveUsers > stats.activeUsers / 2) {
|
|
issues.push('More than half of hub users are inactive');
|
|
}
|
|
if (stats.pendingActivation > 5) {
|
|
issues.push('Many users are pending activation - review onboarding process');
|
|
}
|
|
|
|
const status = issues.length === 0 ? 'healthy' : issues.length <= 2 ? 'degraded' : 'unhealthy';
|
|
|
|
return { status, issues, stats };
|
|
}
|
|
|
|
private async isLastAdmin(userId: string): Promise<boolean> {
|
|
try {
|
|
const [user, userRoles] = await Promise.all([
|
|
this.keycloakApi.getUserById(userId, userId),
|
|
this.keycloakApi.getUserClientRoles(userId)
|
|
]);
|
|
|
|
const isAdmin = userRoles.some(role => role.name === UserRole.DCB_ADMIN);
|
|
if (!isAdmin) return false;
|
|
|
|
const allHubUsers = await this.getAllHubUsers(userId);
|
|
const adminCount = allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length;
|
|
|
|
return adminCount <= 1;
|
|
} catch (error) {
|
|
this.logger.error(`Error checking if user is last admin: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async canUserManageHubUsers(userId: string): Promise<boolean> {
|
|
try {
|
|
const roles = await this.keycloakApi.getUserClientRoles(userId);
|
|
return roles.some(role =>
|
|
this.HUB_ROLES.includes(role.name as UserRole)
|
|
);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
} |