feat: First-Start Create Admin Modal
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user