Files
HIPCTF2/backend/src/modules/auth/auth.service.ts
T
2026-07-23 08:08:47 +00:00

334 lines
12 KiB
TypeScript

import {
Injectable,
OnModuleInit,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, EntityManager } from 'typeorm';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { LoginBackoffService } from '../../common/services/login-backoff.service';
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { SettingsService } from '../settings/settings.module';
import { SETTINGS_KEYS } from '../../config/env.schema';
import { UsersRankService } from '../users/users-rank.service';
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
@Injectable()
export class AuthService implements OnModuleInit {
private readonly logger = new Logger(AuthService.name);
private refreshTtlSeconds = REFRESH_TTL_SECONDS_DEFAULT;
constructor(
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity) private readonly refreshTokens: Repository<RefreshTokenEntity>,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly dataSource: DataSource,
private readonly backoff: LoginBackoffService,
private readonly registrationRateLimit: RegistrationRateLimitService,
private readonly settings: SettingsService,
private readonly rank: UsersRankService,
) {}
onModuleInit(): void {
const ttl = this.config.get<string>('JWT_REFRESH_TTL', '7d');
this.refreshTtlSeconds = this.parseTtl(ttl);
}
async login(dto: LoginDto, ip: string): Promise<LoginResponse> {
const blockedMs = this.backoff.isBlocked(ip, dto.username);
if (blockedMs > 0) {
throw new ApiError(ERROR_CODES.RATE_LIMITED, `Too many failed attempts. Try again in ${Math.ceil(blockedMs / 1000)}s.`, 429);
}
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { username: dto.username } });
if (!user || user.status !== 'enabled') {
this.backoff.recordFailure(ip, dto.username);
throw new ApiError(ERROR_CODES.INVALID_CREDENTIALS, 'Invalid credentials', 401);
}
const ok = await argon2.verify(user.passwordHash, dto.password);
if (!ok) {
this.backoff.recordFailure(ip, dto.username);
throw new ApiError(ERROR_CODES.INVALID_CREDENTIALS, 'Invalid credentials', 401);
}
this.backoff.reset(ip, dto.username);
return this.mintSession(manager, user);
});
}
async refresh(oldRefreshToken: string): Promise<LoginResponse> {
const tokenHash = this.hashToken(oldRefreshToken);
return this.dataSource.transaction(async (manager) => {
const row = await manager.findOne(RefreshTokenEntity, { where: { tokenHash } });
if (!row) throw new ApiError(ERROR_CODES.TOKEN_REVOKED, 'Refresh token revoked', 401);
if (row.revokedAt) throw new ApiError(ERROR_CODES.TOKEN_REVOKED, 'Refresh token revoked', 401);
if (new Date(row.expiresAt).getTime() < Date.now()) throw new ApiError(ERROR_CODES.TOKEN_REVOKED, 'Refresh token expired', 401);
const user = await manager.findOne(UserEntity, { where: { id: row.userId } });
if (!user || user.status !== 'enabled') throw new ApiError(ERROR_CODES.UNAUTHORIZED, 'User not allowed', 401);
row.revokedAt = new Date().toISOString();
await manager.save(row);
return this.mintSession(manager, user);
});
}
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
const tokenHash = this.hashToken(refreshToken);
await this.dataSource.transaction(async (manager) => {
const row = await manager.findOne(RefreshTokenEntity, { where: { tokenHash } });
if (!row || row.revokedAt) return;
row.revokedAt = new Date().toISOString();
await manager.save(row);
});
}
async registerFirstAdmin(username: string, password: string, ip: string): Promise<LoginResponse> {
validatePassword(password, this.config);
if (!this.registrationRateLimit.tryConsume(ip)) {
throw new ApiError(ERROR_CODES.RATE_LIMITED, 'Too many registrations from this IP; try again later.', 429);
}
return this.dataSource.transaction(async (manager) => {
const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } });
if (adminCount > 0) {
throw new ApiError(ERROR_CODES.SYSTEM_INITIALIZED, 'System already initialized', 409);
}
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw new ApiError(ERROR_CODES.CONFLICT, 'Username already taken', 409);
const passwordHash = await hashPassword(password, this.config);
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash,
role: 'admin',
status: 'enabled',
});
await manager.save(user);
return this.mintSession(manager, user);
});
}
async registerPlayer(dto: RegisterDto, ip: string): Promise<LoginResponse> {
validatePassword(dto.password, this.config);
if (!this.registrationRateLimit.tryConsume(ip)) {
throw new ApiError(
ERROR_CODES.RATE_LIMITED,
'Too many registration attempts; please wait a minute before trying again.',
429,
);
}
const enabled = (await this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true';
if (!enabled) {
throw new ApiError(
ERROR_CODES.REGISTRATIONS_DISABLED,
'Registrations are currently disabled',
403,
);
}
return this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username: dto.username } });
if (existing) {
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
}
const passwordHash = await hashPassword(dto.password, this.config);
const user = manager.create(UserEntity, {
id: uuid(),
username: dto.username,
passwordHash,
role: 'player',
status: 'enabled',
});
await manager.save(user);
return this.mintSession(manager, user);
});
}
async getMe(userId: string): Promise<{ id: string; username: string; role: 'admin' | 'player'; rank: number | null; points: number }> {
const user = await this.users.findOne({ where: { id: userId } });
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
const rankInfo = await this.rank.rankOfUser(this.dataSource.manager, user.id);
return {
id: user.id,
username: user.username,
role: user.role,
rank: rankInfo.rank,
points: rankInfo.points,
};
}
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
if (dto.newPassword !== dto.confirmNewPassword) {
throw new ApiError(ERROR_CODES.PASSWORDS_DO_NOT_MATCH, 'Passwords do not match', 400);
}
if (dto.oldPassword === dto.newPassword) {
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, 'New password must differ from the old one', 400);
}
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: userId } });
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
const ok = await argon2.verify(user.passwordHash, dto.oldPassword);
if (!ok) {
throw new ApiError(ERROR_CODES.INVALID_OLD_PASSWORD, 'Old password is incorrect', 401);
}
try {
validatePassword(dto.newPassword, this.config);
} catch (e) {
if (e instanceof ApiError) {
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, e.message, 400);
}
throw e;
}
user.passwordHash = await hashPassword(dto.newPassword, this.config);
await manager.save(user);
// Invalidate all existing refresh tokens for this user.
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
});
}
/**
* Mint a fresh access JWT and a new refresh token row inside the supplied
* transaction. The refresh token (raw) is returned alongside the access
* token so the caller can set the HttpOnly cookie.
*/
private async mintSession(manager: EntityManager, user: UserEntity): Promise<LoginResponse> {
return AuthService.mintSessionWith(
manager,
this.jwt,
this.config,
this.refreshTtlSeconds,
user,
);
}
/**
* Stateless mint helper used by other modules (e.g. setup) that need to
* create a session without depending on the full AuthModule injection tree.
*/
static async mintSessionWith(
manager: EntityManager,
jwt: JwtService,
config: ConfigService,
refreshTtlSeconds: number,
user: UserEntity,
): Promise<LoginResponse> {
const accessToken = await jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
const refreshToken = AuthService.generateRefreshTokenStatic();
const tokenHash = AuthService.hashTokenStatic(refreshToken);
const now = new Date();
const expires = new Date(now.getTime() + refreshTtlSeconds * 1000);
await manager.insert(RefreshTokenEntity, {
id: uuid(),
userId: user.id,
tokenHash,
issuedAt: now.toISOString(),
expiresAt: expires.toISOString(),
revokedAt: null,
});
return {
accessToken,
refreshToken,
expiresIn: AuthService.accessTtlSecondsStatic(config),
user: { id: user.id, username: user.username, role: user.role },
};
}
private static generateRefreshTokenStatic(): string {
return crypto.randomBytes(32).toString('base64url');
}
private static hashTokenStatic(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private static accessTtlSecondsStatic(config: ConfigService): number {
const ttl = config.get<string>('JWT_ACCESS_TTL', '15m');
return AuthService.parseTtlStatic(ttl);
}
private static parseTtlStatic(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return 900;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
/**
* Public access to the static session mint for cross-module reuse.
* The instance-based `mintSession(manager, user)` is preserved for
* existing callers and delegates here.
*/
static async createSession(
manager: EntityManager,
deps: { jwt: JwtService; config: ConfigService; refreshTtlSeconds: number },
user: UserEntity,
): Promise<LoginResponse> {
return AuthService.mintSessionWith(manager, deps.jwt, deps.config, deps.refreshTtlSeconds, user);
}
private async signAccess(user: UserEntity): Promise<string> {
return this.jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
}
private generateRefreshToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private accessTtlSeconds(): number {
return this.parseTtl(this.config.get<string>('JWT_ACCESS_TTL', '15m'));
}
private parseTtl(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return 900;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
}