AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user