AI Implementation feature(821): First-Start Create Admin Modal (#2)

This commit was merged in pull request #2.
This commit is contained in:
2026-07-21 14:45:55 +00:00
parent 03bcb6b156
commit 9f67ec8c50
30 changed files with 1306 additions and 141 deletions
+60 -5
View File
@@ -136,11 +136,31 @@ export class AuthService implements OnModuleInit {
* 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);
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() + this.refreshTtlSeconds * 1000);
const expires = new Date(now.getTime() + refreshTtlSeconds * 1000);
await manager.insert(RefreshTokenEntity, {
id: uuid(),
userId: user.id,
@@ -152,11 +172,46 @@ export class AuthService implements OnModuleInit {
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds(),
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 });
}
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const CreateAdminDtoSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(32, 'Username must be at most 32 characters')
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'),
password: z.string().min(1).max(256),
passwordConfirm: z.string().min(1).max(256),
})
.refine((d) => d.password === d.passwordConfirm, {
message: 'Passwords do not match',
path: ['passwordConfirm'],
});
export type CreateAdminDto = z.infer<typeof CreateAdminDtoSchema>;
@@ -0,0 +1,45 @@
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 { SetupService } from './setup.service';
import { CreateAdminDtoSchema } from './dto/create-admin.dto';
import { setRefreshCookie } from '../auth/cookie';
@ApiTags('setup')
@Controller('api/v1/setup')
export class SetupController {
constructor(
private readonly setup: SetupService,
private readonly config: ConfigService,
) {}
@Public()
@Post('create-admin')
@ApiOperation({
summary:
'Create the very first administrator. Only callable while no admin exists; returns 404 once initialized.',
})
async createAdmin(
@Body(new ZodValidationPipe(CreateAdminDtoSchema))
body: { username: string; password: string; passwordConfirm: string },
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
const session = await this.setup.createAdmin(
body.username,
body.password,
body.passwordConfirm,
ip,
);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { SetupController } from './setup.controller';
import { SetupService } from './setup.service';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
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') },
}),
}),
],
controllers: [SetupController],
providers: [SetupService],
})
export class SetupModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { DataSource, Repository } from 'typeorm';
import * as argon2 from 'argon2';
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 { validatePassword } from '../../common/utils/password-policy';
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { AuthService } from '../auth/auth.service';
import type { LoginResponse } from '../auth/dto/auth.dto';
const REFRESH_TTL_DEFAULT = 7 * 24 * 60 * 60;
@Injectable()
export class SetupService implements OnModuleInit {
private readonly logger = new Logger(SetupService.name);
private refreshTtlSeconds = REFRESH_TTL_DEFAULT;
constructor(
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity) private readonly refreshTokens: Repository<RefreshTokenEntity>,
private readonly dataSource: DataSource,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly registrationRateLimit: RegistrationRateLimitService,
) {}
onModuleInit(): void {
const ttl = this.config.get<string>('JWT_REFRESH_TTL', '7d');
this.refreshTtlSeconds = this.parseTtl(ttl);
}
async createAdmin(
username: string,
password: string,
passwordConfirm: string,
ip: string,
): Promise<LoginResponse> {
if (password !== passwordConfirm) {
throw ApiError.validation('Passwords do not match');
}
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 ApiError.notFound('Setup endpoint not available');
}
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) {
throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists');
}
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);
this.logger.log(`First admin created (username=${username})`);
return AuthService.createSession(
manager,
{ jwt: this.jwt, config: this.config, refreshTtlSeconds: this.refreshTtlSeconds },
user,
);
});
}
private parseTtl(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return REFRESH_TTL_DEFAULT;
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;
}
}
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../../database/entities/user.entity';
@@ -6,6 +7,12 @@ import { SettingEntity } from '../../database/entities/setting.entity';
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
import { SETTINGS_KEYS } from '../../config/env.schema';
export interface PasswordPolicyInfo {
minLength: number;
requireMixed: boolean;
description: string;
}
export interface BootstrapPayload {
initialized: boolean;
pageTitle: string;
@@ -14,6 +21,7 @@ export interface BootstrapPayload {
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
passwordPolicy: PasswordPolicyInfo;
}
@Injectable()
@@ -22,6 +30,7 @@ export class SystemService {
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(SettingEntity) private readonly settings: Repository<SettingEntity>,
private readonly themes: ThemeLoaderService,
private readonly config: ConfigService,
) {}
async bootstrap(): Promise<BootstrapPayload> {
@@ -35,9 +44,19 @@ export class SystemService {
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',
passwordPolicy: this.passwordPolicy(),
};
}
private passwordPolicy(): PasswordPolicyInfo {
const minLength = this.config.get<number>('PASSWORD_MIN_LENGTH', 12);
const requireMixed = this.config.get<boolean>('PASSWORD_REQUIRE_MIXED', true);
const description = requireMixed
? `At least ${minLength} characters with upper, lower, digit and symbol`
: `At least ${minLength} characters`;
return { minLength, requireMixed, description };
}
private async getSetting(key: string, fallback: string): Promise<string> {
const row = await this.settings.findOne({ where: { key } });
return row?.value ?? fallback;