334 lines
12 KiB
TypeScript
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;
|
|
}
|
|
} |