AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-07-21 14:23:53 +00:00
parent e55c9cda56
commit 03bcb6b156
131 changed files with 23657 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
import {
Injectable,
OnModuleInit,
Logger,
} 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 { LoginDto, LoginResponse } from './dto/auth.dto';
import { validatePassword } from '../../common/utils/password-policy';
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,
) {}
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.isAllowed(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 argon2.hash(password, {
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash,
role: 'admin',
status: 'enabled',
});
await manager.save(user);
this.registrationRateLimit.record(ip);
return this.mintSession(manager, user);
});
}
/**
* 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> {
const accessToken = await this.signAccess(user);
const refreshToken = this.generateRefreshToken();
const tokenHash = this.hashToken(refreshToken);
const now = new Date();
const expires = new Date(now.getTime() + this.refreshTtlSeconds * 1000);
await manager.insert(RefreshTokenEntity, {
id: uuid(),
userId: user.id,
tokenHash,
issuedAt: now.toISOString(),
expiresAt: expires.toISOString(),
revokedAt: null,
});
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds(),
user: { id: user.id, username: user.username, role: user.role },
};
}
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;
}
}