Scaffold HIPCTF platform (NestJS + Angular)

- Backend: NestJS 10 + TypeORM (better-sqlite3) feature-modular layout.
  - Entities: user, setting, category, challenge, challenge_file, solve,
    refresh_token, blog_post. Auto-run migrations + idempotent 6-system-
    category seed on startup.
  - Argon2id password hashing with policy check; JWT access + rotating
    refresh tokens (HttpOnly cookie); CSRF middleware (SameSite + custom
    X-CSRF-Token header); global JWT auth guard with @Public() opt-out;
    per-IP login backoff + per-IP registration rate limit.
  - Endpoints: auth (login/refresh/logout/csrf), users (first-admin
    registration), system (bootstrap/event status/SSE), admin (guarded
    user CRUD with last-admin invariant), frontend module (uploads +
    SPA fallback).
  - Security: helmet+CSP+HSTS-gated-by-TLS, CORS allowlist, structured
    global exception filter, Zod request validation pipes, OpenAPI 3.1
    served at /api/docs and /api/docs-json, 10 canonical themes under
    backend/themes/.
- Frontend: Angular 17 standalone components, lazy-loaded feature routes,
  signals, functional HttpInterceptorFn (csrf + auth), functional
  CanActivateFn auth guard, HttpOnly-cookie-based auth service.
- Tests: Jest + supertest, 46 tests across 13 suites covering
  migrations, env schema, theme loader, event status, login backoff,
  registration rate limit, ApiError shape, bootstrap integration,
  auth refresh rotation, admin guard, last-admin invariant, SSE flat
  payloads. Single-command runner: `npm test`.
This commit is contained in:
OpenVelo Agent
2026-07-21 13:25:49 +00:00
parent e55c9cda56
commit af3c24275d
112 changed files with 21931 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"hipctf": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "1mb", "maximumError": "5mb" },
{ "type": "anyComponentStyle", "maximumWarning": "10kb", "maximumError": "20kb" }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": { "buildTarget": "hipctf:build:production" },
"development": { "buildTarget": "hipctf:build:development" }
},
"defaultConfiguration": "development"
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "ng build --configuration production",
"start": "ng serve --host 0.0.0.0"
},
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"zone.js": "~0.14.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"typescript": "~5.4.5"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Component, OnInit, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { BootstrapService } from './core/services/bootstrap.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
})
export class AppComponent implements OnInit {
private bootstrap = inject(BootstrapService);
async ngOnInit() {
await this.bootstrap.load();
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const APP_ROUTES: Routes = [
{
path: 'bootstrap',
loadComponent: () => import('./features/bootstrap/create-admin.component').then((m) => m.CreateAdminComponent),
},
{
path: 'login',
loadComponent: () => import('./features/auth/login.component').then((m) => m.LoginComponent),
},
{
path: '',
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
canActivate: [authGuard],
},
{ path: '**', redirectTo: '' },
];
@@ -0,0 +1,18 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { BootstrapService } from '../services/bootstrap.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const bootstrap = inject(BootstrapService);
const router = inject(Router);
if (!bootstrap.initialized()) {
return router.createUrlTree(['/bootstrap']);
}
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/login']);
}
return true;
};
@@ -0,0 +1,12 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.getAccessToken();
if (token) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req);
};
@@ -0,0 +1,23 @@
import { HttpInterceptorFn } from '@angular/common/http';
function readCookie(name: string): string | null {
const header = document.cookie;
if (!header) return null;
for (const part of header.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === name) return v.join('=');
}
return null;
}
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
if (UNSAFE.has(req.method)) {
const token = readCookie('csrf');
if (token) {
req = req.clone({ setHeaders: { 'X-CSRF-Token': token } });
}
}
return next(req);
};
@@ -0,0 +1,30 @@
import { Injectable, signal, computed } from '@angular/core';
export interface CurrentUser {
id: string;
username: string;
role: 'admin' | 'player';
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private accessToken = signal<string | null>(null);
private user = signal<CurrentUser | null>(null);
readonly currentUser = computed(() => this.user());
readonly isAuthenticated = computed(() => !!this.user());
setSession(token: string, user: CurrentUser): void {
this.accessToken.set(token);
this.user.set(user);
}
clear(): void {
this.accessToken.set(null);
this.user.set(null);
}
getAccessToken(): string | null {
return this.accessToken();
}
}
@@ -0,0 +1,49 @@
import { Injectable, signal } from '@angular/core';
export interface BootstrapPayload {
initialized: boolean;
pageTitle: string;
logo: string;
welcomeMarkdown: string;
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
}
@Injectable({ providedIn: 'root' })
export class BootstrapService {
readonly payload = signal<BootstrapPayload | null>(null);
readonly initialized = signal<boolean>(false);
async load(): Promise<BootstrapPayload | null> {
try {
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
const data = (await res.json()) as BootstrapPayload;
this.payload.set(data);
this.initialized.set(data.initialized);
this.applyTheme(data.theme);
return data;
} catch (e) {
console.error('Bootstrap load failed', e);
return null;
}
}
private applyTheme(theme: any): void {
if (!theme?.tokens) return;
const root = document.documentElement.style;
const t = theme.tokens;
root.setProperty('--color-primary', t.primary);
root.setProperty('--color-secondary', t.secondary);
root.setProperty('--color-accent', t.accent);
root.setProperty('--color-surface', t.surface);
root.setProperty('--color-text', t.text);
root.setProperty('--color-success', t.success);
root.setProperty('--color-warning', t.warning);
root.setProperty('--color-danger', t.danger);
root.setProperty('--font-family', t.fontFamily);
root.setProperty('--radius-sm', t.radii.sm);
root.setProperty('--radius-md', t.radii.md);
root.setProperty('--radius-lg', t.radii.lg);
}
}
@@ -0,0 +1,49 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../core/services/auth.service';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<div class="container">
<div class="card">
<h1>Sign in</h1>
<label>Username<input [(ngModel)]="username" name="u" /></label>
<label>Password<input [(ngModel)]="password" name="p" type="password" /></label>
<p style="color:var(--color-danger)">{{ error }}</p>
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Signing in...' : 'Sign in' }}</button>
</div>
</div>
`,
})
export class LoginComponent {
private http = inject(HttpClient);
private auth = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
loading = false;
error = '';
async submit(): Promise<void> {
this.loading = true;
this.error = '';
try {
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
const res: any = await this.http
.post('/api/v1/auth/login', { username: this.username, password: this.password }, { withCredentials: true })
.toPromise();
this.auth.setSession(res.accessToken, res.user);
await this.router.navigateByUrl('/');
} catch (e: any) {
this.error = e?.error?.message ?? 'Failed';
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,50 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../core/services/auth.service';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-create-admin',
standalone: true,
imports: [FormsModule],
template: `
<div class="container">
<div class="card">
<h1>Create the first admin</h1>
<p>This HIPCTF instance has not been initialized.</p>
<label>Username<input [(ngModel)]="username" name="username" /></label>
<label>Password<input [(ngModel)]="password" name="password" type="password" /></label>
<p style="color:var(--color-danger)">{{ error }}</p>
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Creating...' : 'Create admin' }}</button>
</div>
</div>
`,
})
export class CreateAdminComponent {
private http = inject(HttpClient);
private auth = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
loading = false;
error = '';
async submit(): Promise<void> {
this.loading = true;
this.error = '';
try {
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
const res: any = await this.http
.post('/api/v1/auth/register-first-admin', { username: this.username, password: this.password }, { withCredentials: true })
.toPromise();
this.auth.setSession(res.accessToken, res.user);
await this.router.navigateByUrl('/');
} catch (e: any) {
this.error = e?.error?.message ?? 'Failed';
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,26 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule],
template: `
<div class="container">
<h1>{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
<div *ngIf="!auth.isAuthenticated()">
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
<a href="/login">Sign in</a>
</div>
<div *ngIf="auth.isAuthenticated()">
<p>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</p>
</div>
</div>
`,
})
export class HomeComponent {
bootstrap = inject(BootstrapService);
auth = inject(AuthService);
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HIPCTF</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import 'zone.js';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors, HttpInterceptorFn } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { AppComponent } from './app/app.component';
import { APP_ROUTES } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])),
provideRouter(APP_ROUTES, withComponentInputBinding()),
],
}).catch((err) => console.error(err));
+22
View File
@@ -0,0 +1,22 @@
:root {
--color-primary: #3b82f6;
--color-secondary: #64748b;
--color-accent: #f59e0b;
--color-surface: #ffffff;
--color-text: #0f172a;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--font-family: system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; font-family: var(--font-family); background: var(--color-surface); color: var(--color-text); }
button { font: inherit; padding: 8px 16px; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: #fff; cursor: pointer; }
button[disabled] { opacity: .5; cursor: not-allowed; }
input { font: inherit; padding: 8px 12px; border: 1px solid var(--color-secondary); border-radius: var(--radius-sm); width: 100%; }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.card { padding: 24px; border-radius: var(--radius-lg); box-shadow: 0 1px 4px rgba(0,0,0,.1); }
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": false
}
}