forked from Shared/esg
Implement normalized error handling with Persian translations across all exception types, replace legacy NestJS exceptions with AppException, and add unit, integration, and smoke tests. - Add error catalog with gateway-owned codes and Persian messages - Introduce AppException wrapping normalized error envelopes - Add translateError helper for automatic messageFa population - Remove claims module and update provider error normalization - Add unit tests for error contracts and helper functions - Add integration tests for admin and inquiry endpoints - Add smoke tests for real provider connectivity - Add test support utilities (auth mocks, assertions, app factory)
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { AuditEvent } from '../common/enums/audit-event.enum';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { UsersService, RequestContext } from '../users/users.service';
|
|
import { PasswordService } from './services/password.service';
|
|
import { TokenService } from './services/token.service';
|
|
import { LoginDto } from './dto/login.dto';
|
|
import { AuthTokensDto } from './dto/auth-tokens.dto';
|
|
import { ProfileDto } from './dto/profile.dto';
|
|
import { UserMapper } from '../users/mappers/user.mapper';
|
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
|
import { AuthenticatedUser } from './interfaces/authenticated-user.interface';
|
|
import { AppException } from '../common/exceptions/app-exception';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly usersService: UsersService,
|
|
private readonly passwordService: PasswordService,
|
|
private readonly tokenService: TokenService,
|
|
private readonly auditService: AuditService,
|
|
) {}
|
|
|
|
async login(dto: LoginDto, context: RequestContext = {}): Promise<AuthTokensDto> {
|
|
const user = await this.usersService.findByUsername(dto.username, true);
|
|
|
|
if (!user) {
|
|
await this.auditService.log(AuditEvent.LOGIN_FAILED, {
|
|
username: dto.username,
|
|
ip: context.ip,
|
|
userAgent: context.userAgent,
|
|
metadata: { reason: 'invalid_credentials' },
|
|
});
|
|
throw new AppException('INVALID_CREDENTIALS');
|
|
}
|
|
|
|
if (!user.isActive) {
|
|
await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context);
|
|
throw new AppException('ACCOUNT_INACTIVE');
|
|
}
|
|
|
|
if (user.isBlocked) {
|
|
await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context);
|
|
throw new AppException('ACCOUNT_BLOCKED');
|
|
}
|
|
|
|
const valid = await this.passwordService.compare(dto.password, user.password);
|
|
if (!valid) {
|
|
await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context);
|
|
throw new AppException('INVALID_CREDENTIALS');
|
|
}
|
|
|
|
const tokens = await this.issueTokens(user);
|
|
await this.usersService.saveRefreshToken(user._id.toString(), tokens.refreshToken);
|
|
await this.usersService.recordLogin(user._id.toString());
|
|
|
|
await this.auditService.log(AuditEvent.LOGIN, {
|
|
userId: user._id.toString(),
|
|
username: user.username,
|
|
ip: context.ip,
|
|
userAgent: context.userAgent,
|
|
});
|
|
|
|
return tokens;
|
|
}
|
|
|
|
async refresh(refreshToken: string, context: RequestContext = {}): Promise<AuthTokensDto> {
|
|
let payload: { sub: string };
|
|
try {
|
|
payload = this.tokenService.verifyRefreshToken(refreshToken);
|
|
} catch {
|
|
throw new AppException('INVALID_REFRESH_TOKEN');
|
|
}
|
|
|
|
const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken);
|
|
if (!valid) {
|
|
throw new AppException('INVALID_REFRESH_TOKEN');
|
|
}
|
|
|
|
const user = await this.usersService.findById(payload.sub);
|
|
if (!user.isActive) {
|
|
throw new AppException('ACCOUNT_INACTIVE');
|
|
}
|
|
if (user.isBlocked) {
|
|
throw new AppException('ACCOUNT_BLOCKED');
|
|
}
|
|
|
|
const tokens = await this.issueTokens(user);
|
|
await this.usersService.saveRefreshToken(user._id.toString(), tokens.refreshToken);
|
|
|
|
await this.auditService.log(AuditEvent.LOGIN, {
|
|
userId: user._id.toString(),
|
|
username: user.username,
|
|
ip: context.ip,
|
|
userAgent: context.userAgent,
|
|
metadata: { type: 'refresh' },
|
|
});
|
|
|
|
return tokens;
|
|
}
|
|
|
|
async logout(userId: string, context: RequestContext = {}): Promise<void> {
|
|
await this.usersService.clearRefreshToken(userId);
|
|
const user = await this.usersService.findById(userId);
|
|
|
|
await this.auditService.log(AuditEvent.LOGOUT, {
|
|
userId,
|
|
username: user.username,
|
|
ip: context.ip,
|
|
userAgent: context.userAgent,
|
|
});
|
|
}
|
|
|
|
async getProfile(userId: string): Promise<ProfileDto> {
|
|
const user = await this.usersService.findById(userId);
|
|
return UserMapper.toProfileDto(user);
|
|
}
|
|
|
|
private async issueTokens(user: {
|
|
_id: { toString(): string };
|
|
role: JwtPayload['role'];
|
|
clientType: JwtPayload['clientType'];
|
|
appName: string;
|
|
}): Promise<AuthTokensDto> {
|
|
const payload: JwtPayload = {
|
|
sub: user._id.toString(),
|
|
role: user.role,
|
|
clientType: user.clientType,
|
|
appName: user.appName,
|
|
};
|
|
|
|
const pair = this.tokenService.generateTokenPair(payload);
|
|
return {
|
|
accessToken: pair.accessToken,
|
|
refreshToken: pair.refreshToken,
|
|
tokenType: 'Bearer',
|
|
expiresIn: 900,
|
|
};
|
|
}
|
|
|
|
private async auditFailedLogin(
|
|
userId: string,
|
|
username: string,
|
|
reason: string,
|
|
context: RequestContext,
|
|
): Promise<void> {
|
|
await this.auditService.log(AuditEvent.LOGIN_FAILED, {
|
|
userId,
|
|
username,
|
|
ip: context.ip,
|
|
userAgent: context.userAgent,
|
|
metadata: { reason },
|
|
});
|
|
}
|
|
}
|