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,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UserRole } from '../../database/entities/user.entity';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CreateUserDtoSchema,
|
||||
ListUsersQueryDtoSchema,
|
||||
UpdateUserRoleDtoSchema,
|
||||
UserIdParamDtoSchema,
|
||||
} from './dto/admin.dto';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
/**
|
||||
* All admin handlers use Zod validation via `ZodValidationPipe` and an
|
||||
* INLINE body/param/query type. Using inline types (rather than class
|
||||
* metatypes) avoids the Nest class-transformer pass which would otherwise
|
||||
* overwrite the raw body before our pipe sees it.
|
||||
*/
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'List all users (admin only)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
|
||||
query: { limit?: number; cursor?: string; role?: UserRole },
|
||||
) {
|
||||
return this.admin.listUsers(query);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@ApiOperation({ summary: 'Update a user role (admin only)' })
|
||||
update(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
|
||||
) {
|
||||
return this.admin.updateUserRole(params.id, body.role);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@ApiOperation({ summary: 'Delete a user (admin only)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.admin.deleteUser(params.id);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@ApiOperation({ summary: 'Create a user (admin only)' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(CreateUserDtoSchema)) body: { username: string; password: string; role: UserRole },
|
||||
) {
|
||||
return this.admin.createUser(body.username, body.password, body.role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule, UsersModule],
|
||||
providers: [AdminService],
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface ListUsersQuery {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
||||
const repo = this.dataSource.getRepository(UserEntity);
|
||||
const qb = repo
|
||||
.createQueryBuilder('u')
|
||||
.orderBy('u.createdAt', 'ASC')
|
||||
.addOrderBy('u.id', 'ASC')
|
||||
.limit(limit + 1);
|
||||
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
|
||||
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
|
||||
const rows = await qb.getMany();
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
|
||||
if (role !== 'admin' && role !== 'player') {
|
||||
throw ApiError.validation('Invalid role');
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
|
||||
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
||||
if (!user) throw ApiError.notFound('User not found');
|
||||
user.role = role;
|
||||
await manager.save(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(targetId: string): Promise<void> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.usersService.enforceLastAdminInvariant(manager, targetId);
|
||||
const res = await manager.delete(UserEntity, { id: targetId });
|
||||
if (!res.affected) throw ApiError.notFound('User not found');
|
||||
});
|
||||
}
|
||||
|
||||
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
||||
validatePassword(password, this.config);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const existing = await manager.findOne(UserEntity, { where: { username } });
|
||||
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
|
||||
const hash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username,
|
||||
passwordHash: hash,
|
||||
role,
|
||||
status: 'enabled',
|
||||
});
|
||||
await manager.save(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserDtoSchema = z.object({
|
||||
username: z.string().trim().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, digits, dot, underscore, dash'),
|
||||
password: z.string().min(1).max(256),
|
||||
role: z.enum(['admin', 'player']),
|
||||
});
|
||||
export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;
|
||||
|
||||
export const UpdateUserRoleDtoSchema = z.object({
|
||||
role: z.enum(['admin', 'player']),
|
||||
});
|
||||
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
|
||||
|
||||
export const UserIdParamDtoSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
export type UserIdParamDto = z.infer<typeof UserIdParamDtoSchema>;
|
||||
|
||||
export const ListUsersQueryDtoSchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(200).optional().default(50),
|
||||
cursor: z.string().uuid().optional(),
|
||||
role: z.enum(['admin', 'player']).optional(),
|
||||
});
|
||||
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { LoginDtoSchema, RefreshDtoSchema } from './dto/auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
|
||||
import { setRefreshCookie, clearRefreshCookie } from './cookie';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('api/v1/auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly csrf: CsrfMiddleware,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with username and password' })
|
||||
@ApiBody({ schema: { example: { username: 'admin', password: 'SuperSecret123!' } } })
|
||||
@ApiResponse({ status: 200, description: 'Login successful' })
|
||||
async login(
|
||||
@Body(new ZodValidationPipe(LoginDtoSchema)) body: { username: string; password: string },
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
||||
const session = await this.auth.login(body, ip);
|
||||
setRefreshCookie(res, session.refreshToken, this.config);
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
expiresIn: session.expiresIn,
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Rotate the refresh token' })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body(new ZodValidationPipe(RefreshDtoSchema)) body: { refreshToken?: string },
|
||||
) {
|
||||
const fromCookie = (req.cookies?.['rt'] as string | undefined) ?? '';
|
||||
const token = body.refreshToken ?? fromCookie;
|
||||
if (!token) return { message: 'No refresh token' };
|
||||
const session = await this.auth.refresh(token);
|
||||
setRefreshCookie(res, session.refreshToken, this.config);
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
expiresIn: session.expiresIn,
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Invalidate the current refresh token' })
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
|
||||
const token = (req.cookies?.['rt'] as string | undefined) ?? '';
|
||||
await this.auth.logout(token || undefined);
|
||||
clearRefreshCookie(res);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('csrf')
|
||||
@ApiOperation({ summary: 'Issue a CSRF token (sets csrf cookie, returns token)' })
|
||||
@ApiResponse({ status: 200, schema: { example: { csrfToken: '...' } } })
|
||||
csrfEndpoint(@Req() req: Request, @Res({ passthrough: true }) res: Response): { csrfToken: string } {
|
||||
const token = this.csrf.setOrGetCsrfToken(req, res);
|
||||
return { csrfToken: token };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, AdminGuard],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, JwtModule, AdminGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export const REFRESH_COOKIE_NAME = 'rt';
|
||||
|
||||
export function setRefreshCookie(res: Response, token: string, config: ConfigService): void {
|
||||
const tls = config.get<boolean>('TLS_ENABLED', false);
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: tls ? 'strict' : 'lax',
|
||||
secure: tls,
|
||||
path: '/api/v1/auth',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearRefreshCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/v1/auth' });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LoginDtoSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
export type LoginDto = z.infer<typeof LoginDtoSchema>;
|
||||
|
||||
export const RefreshDtoSchema = z.object({
|
||||
refreshToken: z.string().min(1).optional(),
|
||||
});
|
||||
export type RefreshDto = z.infer<typeof RefreshDtoSchema>;
|
||||
|
||||
export const CsrfResponseDtoSchema = z.object({
|
||||
csrfToken: z.string(),
|
||||
});
|
||||
export type CsrfResponseDto = z.infer<typeof CsrfResponseDtoSchema>;
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: { id: string; username: string; role: 'admin' | 'player' };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: 'admin' | 'player';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_ACCESS_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
if (!payload?.sub) throw new UnauthorizedException();
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { SettingEntity } from '../../database/entities/setting.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(@InjectRepository(SettingEntity) private readonly repo: Repository<SettingEntity>) {}
|
||||
|
||||
async get(key: string, fallback: string = ''): Promise<string> {
|
||||
const row = await this.repo.findOne({ where: { key } });
|
||||
return row?.value ?? fallback;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await this.repo.upsert({ key, value }, ['key']);
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, string>> {
|
||||
const rows = await this.repo.find();
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of rows) out[r.key] = r.value;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SettingEntity])],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Controller, Get, Sse, MessageEvent } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { interval, map, merge, mergeMap, Observable, startWith, defer } from 'rxjs';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { SystemService } from './system.service';
|
||||
import { EventStatusService } from '../../common/services/event-status.service';
|
||||
import { SseHubService } from '../../common/services/sse-hub.service';
|
||||
|
||||
@ApiTags('system')
|
||||
@Controller('api/v1')
|
||||
export class SystemController {
|
||||
constructor(
|
||||
private readonly system: SystemService,
|
||||
private readonly statusSvc: EventStatusService,
|
||||
private readonly hub: SseHubService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get('bootstrap')
|
||||
@ApiOperation({ summary: 'Public bootstrap payload used by the SPA on startup' })
|
||||
bootstrap() {
|
||||
return this.system.bootstrap();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('event/status')
|
||||
@ApiOperation({ summary: 'Event status derived from UTC timestamps' })
|
||||
eventStatus() {
|
||||
return this.statusSvc.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Global event status + countdown stream.
|
||||
* Emits a FLATTENED object every second (and whenever the hub pushes).
|
||||
*/
|
||||
@Public()
|
||||
@Sse('event/stream')
|
||||
eventStream(): Observable<MessageEvent> {
|
||||
const tick$ = interval(1000).pipe(
|
||||
startWith(0),
|
||||
mergeMap(() =>
|
||||
defer(() =>
|
||||
this.statusSvc.getStatus().then((s) => ({
|
||||
status: s.status,
|
||||
countdownMs: s.countdownMs,
|
||||
serverNowUtc: s.serverNowUtc,
|
||||
startUtc: s.startUtc,
|
||||
endUtc: s.endUtc,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
const hub$ = this.hub.event$().pipe(
|
||||
map((payload: any) => ({
|
||||
status: payload?.status,
|
||||
countdownMs: payload?.countdownMs,
|
||||
serverNowUtc: payload?.serverNowUtc,
|
||||
startUtc: payload?.startUtc,
|
||||
endUtc: payload?.endUtc,
|
||||
})),
|
||||
);
|
||||
return merge(tick$, hub$).pipe(map((src) => ({ data: src }) as MessageEvent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Live solves stream.
|
||||
* Emits a FLATTENED solve payload whenever a new solve is published.
|
||||
*/
|
||||
@Public()
|
||||
@Sse('scoreboard/stream')
|
||||
scoreboardStream(): Observable<MessageEvent> {
|
||||
return this.hub.scoreboard$().pipe(
|
||||
map((s: any) => ({
|
||||
challengeId: s?.challengeId,
|
||||
userId: s?.userId,
|
||||
pointsAwarded: s?.pointsAwarded,
|
||||
rankBonus: s?.rankBonus,
|
||||
solvedAt: s?.solvedAt,
|
||||
})),
|
||||
map((src) => ({ data: src }) as MessageEvent),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { SettingEntity } from '../../database/entities/setting.entity';
|
||||
import { SystemController } from './system.controller';
|
||||
import { SystemService } from './system.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity, SettingEntity])],
|
||||
controllers: [SystemController],
|
||||
providers: [SystemService],
|
||||
})
|
||||
export class SystemModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { SettingEntity } from '../../database/entities/setting.entity';
|
||||
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
|
||||
export interface BootstrapPayload {
|
||||
initialized: boolean;
|
||||
pageTitle: string;
|
||||
logo: string;
|
||||
welcomeMarkdown: string;
|
||||
theme: any;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
|
||||
@InjectRepository(SettingEntity) private readonly settings: Repository<SettingEntity>,
|
||||
private readonly themes: ThemeLoaderService,
|
||||
) {}
|
||||
|
||||
async bootstrap(): Promise<BootstrapPayload> {
|
||||
const adminCount = await this.users.count({ where: { role: 'admin' } });
|
||||
const themeKey = await this.getSetting(SETTINGS_KEYS.THEME_KEY, 'classic');
|
||||
return {
|
||||
initialized: adminCount > 0,
|
||||
pageTitle: await this.getSetting(SETTINGS_KEYS.PAGE_TITLE, 'HIPCTF'),
|
||||
logo: await this.getSetting(SETTINGS_KEYS.LOGO, ''),
|
||||
welcomeMarkdown: await this.getSetting(SETTINGS_KEYS.WELCOME_MARKDOWN, ''),
|
||||
theme: this.themes.getTheme(themeKey),
|
||||
defaultChallengeIp: await this.getSetting(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
|
||||
registrationsEnabled: (await this.getSetting(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
private async getSetting(key: string, fallback: string): Promise<string> {
|
||||
const row = await this.settings.findOne({ where: { key } });
|
||||
return row?.value ?? fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Controller, Post, UseGuards, UseInterceptors, Req, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
|
||||
|
||||
@ApiTags('uploads')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/uploads')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class UploadsController {
|
||||
private readonly uploadDir: string;
|
||||
private readonly globalLimit: number;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads'));
|
||||
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '50mb'));
|
||||
}
|
||||
|
||||
@Post('category-icon')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload a category icon (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadCategoryIcon(@Req() req: any) {
|
||||
return this.handleUpload(req, 'icons');
|
||||
}
|
||||
|
||||
@Post('challenge-file')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload a challenge attachment (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadChallengeFile(@Req() req: any) {
|
||||
return this.handleUpload(req, 'challenges');
|
||||
}
|
||||
|
||||
private handleUpload(req: any, subdir: string) {
|
||||
const file = req.file as any;
|
||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||
if (file.size > this.globalLimit) {
|
||||
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
||||
}
|
||||
const safeName = safeFilename(file.originalname || 'file');
|
||||
const finalDir = path.join(this.uploadDir, subdir);
|
||||
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
|
||||
const finalPath = path.join(finalDir, safeName);
|
||||
// FileInterceptor uses memoryStorage; the bytes are in file.buffer.
|
||||
fs.writeFileSync(finalPath, file.buffer);
|
||||
const publicUrl = `/uploads/${subdir}/${safeName}`;
|
||||
return {
|
||||
id: safeName,
|
||||
originalFilename: file.originalname,
|
||||
storedPath: finalPath,
|
||||
publicUrl,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadsController } from './uploads.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UploadsController],
|
||||
})
|
||||
export class UploadsModule {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateFirstAdminDtoSchema = z.object({
|
||||
username: z.string().min(3).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
export type CreateFirstAdminDto = z.infer<typeof CreateFirstAdminDtoSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { CreateFirstAdminDtoSchema } from './dto/create-first-admin.dto';
|
||||
import { setRefreshCookie } from '../auth/cookie';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('api/v1/auth')
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('register-first-admin')
|
||||
@ApiOperation({ summary: 'Create the very first admin (only allowed when no admins exist)' })
|
||||
async registerFirstAdmin(
|
||||
@Body(new ZodValidationPipe(CreateFirstAdminDtoSchema)) body: { username: string; password: string },
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
||||
const session = await this.auth.registerFirstAdmin(body.username, body.password, ip);
|
||||
setRefreshCookie(res, session.refreshToken, this.config);
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
expiresIn: session.expiresIn,
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule],
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
|
||||
type UserRepoLike = Repository<UserEntity> | EntityManager;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>) {}
|
||||
|
||||
countAdmins(manager?: EntityManager): Promise<number> {
|
||||
return this.getRepo(manager).count({ where: { role: 'admin' } });
|
||||
}
|
||||
|
||||
findById(id: string, manager?: EntityManager): Promise<UserEntity | null> {
|
||||
return this.getRepo(manager).findOne({ where: { id } });
|
||||
}
|
||||
|
||||
listAll(): Promise<UserEntity[]> {
|
||||
return this.repo.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify, inside an open transaction, that demoting/deleting the given user
|
||||
* would not leave the system with zero admins. Throws ApiError(LAST_ADMIN)
|
||||
* if so. Must be invoked from a `dataSource.transaction(...)` callback.
|
||||
*/
|
||||
async enforceLastAdminInvariant(manager: EntityManager, targetId: string, nextRole?: UserRole): Promise<void> {
|
||||
const repo = manager.getRepository(UserEntity);
|
||||
const target = await repo.findOne({ where: { id: targetId } });
|
||||
if (!target) throw ApiError.notFound('User not found');
|
||||
|
||||
const wouldDemoteOrDelete =
|
||||
target.role === 'admin' && (nextRole === undefined || nextRole !== 'admin');
|
||||
if (!wouldDemoteOrDelete) return;
|
||||
|
||||
const adminCount = await repo.count({ where: { role: 'admin' } });
|
||||
if (adminCount <= 1) {
|
||||
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, 'Cannot delete or demote the last admin');
|
||||
}
|
||||
}
|
||||
|
||||
private getRepo(manager?: EntityManager): Repository<UserEntity> {
|
||||
return manager ? manager.getRepository(UserEntity) : this.repo;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user