feat: 添加用户认证系统

引入基于 Basic Auth 的用户认证系统,包括用户管理、登录界面和 API 鉴权
- 新增用户实体和管理功能
- 实现前端登录界面和凭证管理
- 重构 API 鉴权为 Basic Auth 模式
- 添加用户管理脚本工具
This commit is contained in:
dmy
2026-01-18 12:47:16 +08:00
parent a55dfd78d2
commit b6a6398864
30 changed files with 2042 additions and 82 deletions

View File

@@ -12,6 +12,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { LoggingMiddleware } from './common/logger/logging.middleware';
import { AiModule } from './ai/ai.module';
import { AuthModule } from './common/auth/auth.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
@@ -23,6 +24,7 @@ import { AuthModule } from './common/auth/auth.module';
}),
LoggerModule,
AuthModule,
UsersModule,
DatabaseModule,
BidsModule,
KeywordsModule,

View File

@@ -6,78 +6,55 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { UsersService } from '../../users/users.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
constructor(
private configService: ConfigService,
private usersService: UsersService,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const clientIp = this.getClientIp(request);
// 检查是否为本地 IP
if (this.isLocalIp(clientIp)) {
return true; // 本地访问直接放行
}
// 检查是否启用 Basic Auth
const enableBasicAuth =
this.configService.get<string>('ENABLE_BASIC_AUTH') === 'true';
// 公网访问需要验证 API Key
const apiKey = request.headers['x-api-key'] as string;
const expectedApiKey = this.configService.get<string>('API_KEY');
if (!expectedApiKey) {
// 如果未配置 API_KEY允许所有访问开发环境
if (!enableBasicAuth) {
// 如果未启用 Basic Auth允许所有访问
return true;
}
if (!apiKey || apiKey !== expectedApiKey) {
throw new UnauthorizedException('Invalid or missing API Key');
// 解析 Authorization header
const authHeader = request.headers['authorization'] as string;
if (!authHeader || !authHeader.startsWith('Basic ')) {
throw new UnauthorizedException('Missing or invalid Authorization header');
}
// 解码 Basic Auth
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString(
'utf-8',
);
const [username, password] = credentials.split(':');
if (!username || !password) {
throw new UnauthorizedException('Invalid credentials format');
}
// 验证用户
const user = await this.usersService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException('Invalid username or password');
}
// 将用户信息附加到请求对象
(request as any).user = user;
return true;
}
/**
* 获取客户端真实 IP
* 支持反向代理场景X-Forwarded-For
*/
private getClientIp(request: Request): string {
const forwardedFor = request.headers['x-forwarded-for'];
if (forwardedFor) {
// X-Forwarded-For 可能包含多个 IP取第一个
const ips = (forwardedFor as string).split(',');
return ips[0].trim();
}
return request.ip || request.socket.remoteAddress || 'unknown';
}
/**
* 判断是否为本地 IP
*/
private isLocalIp(ip: string): boolean {
if (!ip || ip === 'unknown') {
return false;
}
// IPv4 本地地址
if (ip === '127.0.0.1' || ip === 'localhost' || ip.startsWith('127.')) {
return true;
}
// IPv6 本地地址
if (ip === '::1' || ip === '::ffff:127.0.0.1') {
return true;
}
// 内网地址(可选,根据需求决定是否允许)
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
const privateIpRegex =
/^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
if (privateIpRegex.test(ip)) {
// 如果需要内网也免鉴权,可以返回 true
// 这里默认返回 false内网也需要鉴权
return false;
}
return false;
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';
import { UsersModule } from '../../users/users.module';
@Module({
imports: [UsersModule],
providers: [
{
provide: APP_GUARD,

View File

@@ -1,6 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../users/entities/user.entity';
import { BidItem } from '../bids/entities/bid-item.entity';
import { CrawlInfoAdd } from '../crawler/entities/crawl-info-add.entity';
import { AiRecommendation } from '../ai/entities/ai-recommendation.entity';
import { Keyword } from '../keywords/keyword.entity';
@Module({
imports: [
@@ -18,11 +23,19 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
username: configService.get<string>('DATABASE_USERNAME', 'root'),
password: configService.get<string>('DATABASE_PASSWORD', 'root'),
database: configService.get<string>('DATABASE_NAME', 'bidding'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
entities: [
User,
BidItem,
CrawlInfoAdd,
AiRecommendation,
Keyword,
__dirname + '/../**/*.entity{.ts,.js}',
],
synchronize: false,
timezone: 'Z',
}),
}),
],
exports: [TypeOrmModule],
})
export class DatabaseModule {}

100
src/scripts/create-user.ts Normal file
View File

@@ -0,0 +1,100 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcrypt';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function createUser() {
let dataSource: DataSource | null = null;
try {
// 从命令行参数获取用户名和密码
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('用法: npm run user:create <用户名> <密码>');
console.log('示例: npm run user:create admin password123');
process.exit(1);
}
const username = args[0];
const password = args[1];
if (!username || username.trim().length === 0) {
logger.error('用户名不能为空');
process.exit(1);
}
if (!password || password.length === 0) {
logger.error('密码不能为空');
process.exit(1);
}
logger.log('开始创建用户...');
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 检查用户名是否已存在
const userRepository = dataSource.getRepository(User);
const existingUser = await userRepository.findOne({
where: { username },
});
if (existingUser) {
logger.error(`用户名 ${username} 已存在`);
process.exit(1);
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建用户
const user = userRepository.create({
username,
password: hashedPassword,
});
await userRepository.save(user);
logger.log(`用户 ${username} 创建成功!`);
logger.log(`用户 ID: ${user.id}`);
logger.log(`创建时间: ${user.createdAt}`);
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('创建用户失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行创建用户
createUser();

View File

@@ -0,0 +1,78 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function deleteUser() {
let dataSource: DataSource | null = null;
try {
// 获取命令行参数
const usernameToDelete = process.argv[2];
if (!usernameToDelete) {
logger.error('请提供要删除的用户名');
console.log('用法: npm run user:delete <username>');
console.log('示例: npm run user:delete testuser');
process.exit(1);
}
logger.log(`开始删除用户: ${usernameToDelete}...`);
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 获取用户
const userRepository = dataSource.getRepository(User);
const user = await userRepository.findOne({
where: { username: usernameToDelete },
});
if (!user) {
logger.error(`用户 "${usernameToDelete}" 不存在`);
await dataSource.destroy();
process.exit(1);
}
// 删除用户
await userRepository.delete(user.id);
logger.log(`用户 "${usernameToDelete}" 删除成功!`);
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('删除用户失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行删除用户
deleteUser();

View File

@@ -0,0 +1,55 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: true, // 启用自动同步以创建表
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function initUsersTable() {
let dataSource: DataSource | null = null;
try {
logger.log('开始初始化 users 表...');
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 同步表结构
await dataSource.synchronize();
logger.log('users 表创建成功!');
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('初始化 users 表失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行初始化
initUsersTable();

72
src/scripts/list-users.ts Normal file
View File

@@ -0,0 +1,72 @@
import 'dotenv/config';
import { DataSource, DataSourceOptions } from 'typeorm';
import { User } from '../users/entities/user.entity';
// 数据库配置
const dbConfig: DataSourceOptions = {
type: (process.env.DATABASE_TYPE as any) || 'mysql',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '3306'),
username: process.env.DATABASE_USERNAME || 'root',
password: process.env.DATABASE_PASSWORD || 'root',
database: process.env.DATABASE_NAME || 'bidding',
entities: [User],
synchronize: false,
};
// 日志工具
const logger = {
log: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
};
// 主函数
async function listUsers() {
let dataSource: DataSource | null = null;
try {
logger.log('开始获取用户列表...');
// 创建数据库连接
dataSource = new DataSource(dbConfig);
await dataSource.initialize();
logger.log('数据库连接成功');
// 获取所有用户
const userRepository = dataSource.getRepository(User);
const users = await userRepository.find();
if (users.length === 0) {
logger.log('当前没有用户');
} else {
logger.log(`共有 ${users.length} 个用户:`);
console.log('');
console.log('ID | 用户名 | 创建时间 | 更新时间');
console.log('------------------------------------|-------------|------------------------|------------------------');
users.forEach((user) => {
const id = user.id;
const username = user.username.padEnd(11);
const createdAt = user.createdAt.toISOString().replace('T', ' ').substring(0, 19);
const updatedAt = user.updatedAt.toISOString().replace('T', ' ').substring(0, 19);
console.log(`${id} | ${username} | ${createdAt} | ${updatedAt}`);
});
console.log('');
}
await dataSource.destroy();
process.exit(0);
} catch (error) {
logger.error('获取用户列表失败:', error);
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
}
process.exit(1);
}
}
// 执行列出用户
listUsers();

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
username: string;
@Column()
password: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

11
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

112
src/users/users.service.ts Normal file
View File

@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
/**
* 创建新用户
* @param username 用户名
* @param password 明文密码
* @returns 创建的用户
*/
async createUser(username: string, password: string): Promise<User> {
// 检查用户名是否已存在
const existingUser = await this.userRepository.findOne({
where: { username },
});
if (existingUser) {
throw new Error(`用户名 ${username} 已存在`);
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建用户
const user = this.userRepository.create({
username,
password: hashedPassword,
});
return await this.userRepository.save(user);
}
/**
* 验证用户名和密码
* @param username 用户名
* @param password 明文密码
* @returns 验证成功返回用户对象,失败返回 null
*/
async validateUser(
username: string,
password: string,
): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { username },
});
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return null;
}
return user;
}
/**
* 获取所有用户(不包含密码)
* @returns 用户列表
*/
async findAll(): Promise<Omit<User, 'password'>[]> {
const users = await this.userRepository.find();
return users.map((user) => {
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
});
}
/**
* 根据用户名查找用户
* @param username 用户名
* @returns 用户对象(不包含密码)
*/
async findByUsername(
username: string,
): Promise<Omit<User, 'password'> | null> {
const user = await this.userRepository.findOne({
where: { username },
});
if (!user) {
return null;
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
/**
* 删除用户
* @param id 用户 ID
*/
async deleteUser(id: string): Promise<void> {
await this.userRepository.delete(id);
}
/**
* 根据用户名删除用户
* @param username 用户名
*/
async deleteUserByUsername(username: string): Promise<void> {
await this.userRepository.delete({ username });
}
}