feat(auth): implement API key authentication and enhance request handling

- Add AuthGuard to validate API key for public access.
- Create AuthModule to provide the AuthGuard globally.
- Update API request interceptor to automatically include API key for non-localhost requests.
- Modify .env and .env.example to include API_KEY configuration.
- Enhance API request handling with improved error logging and client IP detection.
This commit is contained in:
dmy
2026-01-16 11:26:02 +08:00
parent 9257c78e72
commit 300e930c64
7 changed files with 144 additions and 19 deletions

3
.env
View File

@@ -43,3 +43,6 @@ LOG_LEVEL=debug
ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a
SSH_PASSPHRASE=x SSH_PASSPHRASE=x
API_KEY=22c64b60-6e60-433c-991d-f6d658024b9e

View File

@@ -25,3 +25,5 @@ LOG_LEVEL=info
# OpenAI API Key (用于 AI 推荐) # OpenAI API Key (用于 AI 推荐)
ARK_API_KEY=your_openai_api_key_here ARK_API_KEY=your_openai_api_key_here
API_KEY=your_secure_api_key_here

View File

@@ -1,34 +1,52 @@
import axios from 'axios' import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
/** /**
* API配置 * API配置
* 配置axios实例设置baseURL和请求拦截器 * 配置axios实例设置baseURL和请求拦截器
*/ */
const api = axios.create({ const api = axios.create({
baseURL: 'http://localhost:3000', // 设置后端服务地址 baseURL:
(import.meta.env.VITE_API_BASE_URL as string) || 'http://localhost:3000', // 设置后端服务地址
timeout: 120000, // 请求超时时间120秒 timeout: 120000, // 请求超时时间120秒
}) });
// 请求拦截器 // 请求拦截器
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config: AxiosRequestConfig) => {
// 可以在这里添加认证信息等 // 如果 baseURL 不是 localhost自动添加 API Key
return config const baseURL =
}, (config.baseURL as string) ||
(error) => { (api.defaults.baseURL as string) ||
return Promise.reject(error) '';
const isLocalhost =
baseURL.includes('localhost') || baseURL.includes('127.0.0.1');
if (!isLocalhost) {
// 从环境变量或 localStorage 获取 API Key
const apiKey =
(import.meta.env.VITE_API_KEY as string) ||
localStorage.getItem('apiKey');
if (apiKey && config.headers) {
config.headers['X-API-Key'] = apiKey;
} }
) }
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
},
);
// 响应拦截器 // 响应拦截器
api.interceptors.response.use( api.interceptors.response.use(
(response) => { (response) => {
return response return response;
}, },
(error) => { (error: AxiosError) => {
console.error('API请求错误:', error) console.error('API请求错误:', error);
return Promise.reject(error) return Promise.reject(error);
} },
) );
export default api export default api;

View File

@@ -11,6 +11,7 @@ import { TasksModule } from './schedule/schedule.module';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { LoggingMiddleware } from './common/logger/logging.middleware'; import { LoggingMiddleware } from './common/logger/logging.middleware';
import { AiModule } from './ai/ai.module'; import { AiModule } from './ai/ai.module';
import { AuthModule } from './common/auth/auth.module';
@Module({ @Module({
imports: [ imports: [
@@ -21,6 +22,7 @@ import { AiModule } from './ai/ai.module';
exclude: ['/api'], exclude: ['/api'],
}), }),
LoggerModule, LoggerModule,
AuthModule,
DatabaseModule, DatabaseModule,
BidsModule, BidsModule,
KeywordsModule, KeywordsModule,

View File

@@ -0,0 +1,83 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const clientIp = this.getClientIp(request);
// 检查是否为本地 IP
if (this.isLocalIp(clientIp)) {
return true; // 本地访问直接放行
}
// 公网访问需要验证 API Key
const apiKey = request.headers['x-api-key'] as string;
const expectedApiKey = this.configService.get<string>('API_KEY');
if (!expectedApiKey) {
// 如果未配置 API_KEY允许所有访问开发环境
return true;
}
if (!apiKey || apiKey !== expectedApiKey) {
throw new UnauthorizedException('Invalid or missing API Key');
}
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

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AuthModule {}

View File

@@ -21,6 +21,10 @@ async function bootstrap() {
// 启用 CORS // 启用 CORS
app.enableCors(); app.enableCors();
// 信任代理(用于获取真实客户端 IP
const httpAdapter = app.getHttpAdapter();
httpAdapter.getInstance().set('trust proxy', true);
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
void bootstrap(); void bootstrap();