feat:简单添加管理员后台功能

This commit is contained in:
jianuo
2025-12-19 19:17:47 +08:00
parent 17c16588aa
commit dd4fb6edd3
29 changed files with 1431 additions and 3 deletions

View File

@@ -0,0 +1,27 @@
/**
* 管理员核心模块
*
* 功能描述:
* - 提供管理员登录鉴权能力签名Token
* - 提供管理员账户启动引导(可选)
* - 为业务层 AdminModule 提供可复用的核心服务
*
* 依赖模块:
* - UsersModule: 用户数据访问(数据库/内存双模式)
* - ConfigModule: 环境变量与配置读取
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AdminCoreService } from './admin_core.service';
@Module({
imports: [ConfigModule],
providers: [AdminCoreService],
exports: [AdminCoreService],
})
export class AdminCoreModule {}

View File

@@ -0,0 +1,285 @@
/**
* 管理员核心服务
*
* 功能描述:
* - 管理员登录校验(仅允许 role=9
* - 生成/验证管理员签名TokenHMAC-SHA256
* - 启动时可选引导创建管理员账号(通过环境变量启用)
*
* 安全说明:
* - 本服务生成的Token为对称签名Token非JWT标准实现但具备有效期与签名校验
* - 仅用于后台管理用途;生产环境务必配置强随机的 ADMIN_TOKEN_SECRET
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { Users } from '../db/users/users.entity';
import { UsersService } from '../db/users/users.service';
import { UsersMemoryService } from '../db/users/users_memory.service';
export interface AdminLoginRequest {
identifier: string;
password: string;
}
export interface AdminAuthPayload {
adminId: string;
username: string;
role: number;
iat: number;
exp: number;
}
export interface AdminLoginResult {
admin: {
id: string;
username: string;
nickname: string;
role: number;
};
access_token: string;
expires_at: number;
}
@Injectable()
export class AdminCoreService implements OnModuleInit {
private readonly logger = new Logger(AdminCoreService.name);
constructor(
private readonly configService: ConfigService,
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
) {}
async onModuleInit(): Promise<void> {
await this.bootstrapAdminIfEnabled();
}
/**
* 管理员登录
*/
async login(request: AdminLoginRequest): Promise<AdminLoginResult> {
const { identifier, password } = request;
const adminUser = await this.findUserByIdentifier(identifier);
if (!adminUser) {
throw new UnauthorizedException('管理员账号不存在');
}
if (adminUser.role !== 9) {
throw new UnauthorizedException('无管理员权限');
}
if (!adminUser.password_hash) {
throw new UnauthorizedException('管理员账户未设置密码,无法登录');
}
const ok = await bcrypt.compare(password, adminUser.password_hash);
if (!ok) {
throw new UnauthorizedException('密码错误');
}
const ttlSeconds = this.getAdminTokenTtlSeconds();
const now = Date.now();
const payload: AdminAuthPayload = {
adminId: adminUser.id.toString(),
username: adminUser.username,
role: adminUser.role,
iat: now,
exp: now + ttlSeconds * 1000,
};
const token = this.signPayload(payload);
return {
admin: {
id: adminUser.id.toString(),
username: adminUser.username,
nickname: adminUser.nickname,
role: adminUser.role,
},
access_token: token,
expires_at: payload.exp,
};
}
/**
* 校验管理员Token并返回Payload
*/
verifyToken(token: string): AdminAuthPayload {
const secret = this.getAdminTokenSecret();
const [payloadPart, signaturePart] = token.split('.');
if (!payloadPart || !signaturePart) {
throw new UnauthorizedException('Token格式错误');
}
const expected = this.hmacSha256Base64Url(payloadPart, secret);
if (!this.safeEqual(signaturePart, expected)) {
throw new UnauthorizedException('Token签名无效');
}
const payloadJson = Buffer.from(this.base64UrlToBase64(payloadPart), 'base64').toString('utf-8');
let payload: AdminAuthPayload;
try {
payload = JSON.parse(payloadJson) as AdminAuthPayload;
} catch {
throw new UnauthorizedException('Token解析失败');
}
if (!payload?.adminId || payload.role !== 9) {
throw new UnauthorizedException('无管理员权限');
}
if (typeof payload.exp !== 'number' || Date.now() > payload.exp) {
throw new UnauthorizedException('Token已过期');
}
return payload;
}
/**
* 管理员重置用户密码(直接设置新密码)
*/
async resetUserPassword(userId: bigint, newPassword: string): Promise<void> {
this.validatePasswordStrength(newPassword);
const passwordHash = await this.hashPassword(newPassword);
await this.usersService.update(userId, { password_hash: passwordHash });
}
private async findUserByIdentifier(identifier: string): Promise<Users | null> {
const byUsername = await this.usersService.findByUsername(identifier);
if (byUsername) return byUsername;
if (this.isEmail(identifier)) {
const byEmail = await this.usersService.findByEmail(identifier);
if (byEmail) return byEmail;
}
if (this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll(1000, 0);
return users.find((u: Users) => u.phone === identifier) || null;
}
return null;
}
private async bootstrapAdminIfEnabled(): Promise<void> {
const enabled = this.configService.get<string>('ADMIN_BOOTSTRAP_ENABLED', 'false') === 'true';
if (!enabled) return;
const username = this.configService.get<string>('ADMIN_USERNAME');
const password = this.configService.get<string>('ADMIN_PASSWORD');
const nickname = this.configService.get<string>('ADMIN_NICKNAME', '管理员');
if (!username || !password) {
this.logger.warn('已启用管理员引导,但未配置 ADMIN_USERNAME / ADMIN_PASSWORD跳过创建');
return;
}
const existing = await this.usersService.findByUsername(username);
if (existing) {
if (existing.role !== 9) {
this.logger.warn(`管理员引导发现同名用户但role!=9${username},跳过`);
}
return;
}
this.validatePasswordStrength(password);
const passwordHash = await this.hashPassword(password);
await this.usersService.create({
username,
password_hash: passwordHash,
nickname,
role: 9,
email_verified: true,
});
this.logger.log(`管理员账号已创建:${username} (role=9)`);
}
private getAdminTokenSecret(): string {
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
if (!secret || secret.length < 16) {
throw new BadRequestException('ADMIN_TOKEN_SECRET 未配置或过短至少16字符');
}
return secret;
}
private getAdminTokenTtlSeconds(): number {
const raw = this.configService.get<string>('ADMIN_TOKEN_TTL_SECONDS', '28800'); // 8h
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 28800;
}
return parsed;
}
private signPayload(payload: AdminAuthPayload): string {
const secret = this.getAdminTokenSecret();
const payloadJson = JSON.stringify(payload);
const payloadPart = this.base64ToBase64Url(Buffer.from(payloadJson, 'utf-8').toString('base64'));
const signature = this.hmacSha256Base64Url(payloadPart, secret);
return `${payloadPart}.${signature}`;
}
private hmacSha256Base64Url(data: string, secret: string): string {
const digest = crypto.createHmac('sha256', secret).update(data).digest('base64');
return this.base64ToBase64Url(digest);
}
private base64ToBase64Url(base64: string): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
private base64UrlToBase64(base64Url: string): string {
const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (padded.length % 4)) % 4;
return padded + '='.repeat(padLen);
}
private safeEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
private isEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
private isPhoneNumber(value: string): boolean {
return /^\+?[0-9\-\s]{6,20}$/.test(value);
}
private validatePasswordStrength(password: string): void {
if (password.length < 8) {
throw new BadRequestException('密码长度至少8位');
}
if (password.length > 128) {
throw new BadRequestException('密码长度不能超过128位');
}
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasLetter || !hasNumber) {
throw new BadRequestException('密码必须包含字母和数字');
}
}
private async hashPassword(password: string): Promise<string> {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
}

View File

@@ -0,0 +1,43 @@
/**
* 管理员鉴权守卫
*
* 功能描述:
* - 保护后台管理接口
* - 校验 Authorization: Bearer <admin_token>
* - 仅允许 role=9 的管理员访问
*
* @author jianuo
* @version 1.0.0
* @since 2025-12-19
*/
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service';
export interface AdminRequest extends Request {
admin?: AdminAuthPayload;
}
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private readonly adminCoreService: AdminCoreService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<AdminRequest>();
const auth = req.headers['authorization'];
if (!auth || Array.isArray(auth)) {
throw new UnauthorizedException('缺少Authorization头');
}
const [scheme, token] = auth.split(' ');
if (scheme !== 'Bearer' || !token) {
throw new UnauthorizedException('Authorization格式错误');
}
const payload = this.adminCoreService.verifyToken(token);
req.admin = payload;
return true;
}
}

View File

@@ -55,7 +55,7 @@ describe('LoginCoreService', () => {
providers: [
LoginCoreService,
{
provide: UsersService,
provide: 'UsersService',
useValue: mockUsersService,
},
{
@@ -70,7 +70,7 @@ describe('LoginCoreService', () => {
}).compile();
service = module.get<LoginCoreService>(LoginCoreService);
usersService = module.get(UsersService);
usersService = module.get('UsersService');
emailService = module.get(EmailService);
verificationService = module.get(VerificationService);
});