mirror of
https://github.com/fish2018/pansou.git
synced 2025-11-25 03:14:59 +08:00
增加认证
This commit is contained in:
@@ -52,6 +52,7 @@ EXPOSE 8888
|
||||
|
||||
# 设置环境变量
|
||||
# ENABLED_PLUGINS: 必须指定启用的插件,多个插件用逗号分隔
|
||||
# AUTH_ENABLED: 认证功能默认关闭,可通过环境变量启用
|
||||
ENV CACHE_PATH=/app/cache \
|
||||
CACHE_ENABLED=true \
|
||||
TZ=Asia/Shanghai \
|
||||
@@ -60,7 +61,9 @@ ENV CACHE_PATH=/app/cache \
|
||||
ASYNC_MAX_BACKGROUND_WORKERS=20 \
|
||||
ASYNC_MAX_BACKGROUND_TASKS=100 \
|
||||
ASYNC_CACHE_TTL_HOURS=1 \
|
||||
ENABLED_PLUGINS=labi,zhizhen,shandian,duoduo,muou,wanou
|
||||
ENABLED_PLUGINS=labi,zhizhen,shandian,duoduo,muou,wanou \
|
||||
AUTH_ENABLED=false \
|
||||
AUTH_TOKEN_EXPIRY=24
|
||||
|
||||
# 构建参数
|
||||
ARG VERSION=dev
|
||||
|
||||
50
README.md
50
README.md
@@ -106,6 +106,56 @@ cd pansou
|
||||
| **CHANNELS** | 默认搜索的TG频道 | `tgsearchers3` | 多个频道用逗号分隔 |
|
||||
| **ENABLED_PLUGINS** | 指定启用插件,多个插件用逗号分隔 | 无 | 必须显式指定 |
|
||||
|
||||
#### 认证配置(可选)
|
||||
|
||||
PanSou支持可选的安全认证功能,默认关闭。开启后,所有API接口(除登录接口外)都需要提供有效的JWT Token。详见[认证系统设计文档](docs/认证系统设计.md)。
|
||||
|
||||
| 环境变量 | 描述 | 默认值 | 说明 |
|
||||
|----------|------|--------|------|
|
||||
| **AUTH_ENABLED** | 是否启用认证 | `false` | 设置为`true`启用认证功能 |
|
||||
| **AUTH_USERS** | 用户账号配置 | 无 | 格式:`user1:pass1,user2:pass2` |
|
||||
| **AUTH_TOKEN_EXPIRY** | Token有效期(小时) | `24` | JWT Token的有效时长 |
|
||||
| **AUTH_JWT_SECRET** | JWT签名密钥 | 自动生成 | 用于签名Token,建议手动设置 |
|
||||
|
||||
**认证配置示例:**
|
||||
|
||||
```bash
|
||||
# 启用认证并配置单个用户
|
||||
docker run -d --name pansou -p 8888:8888 \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e AUTH_USERS=admin:admin123 \
|
||||
-e AUTH_TOKEN_EXPIRY=24 \
|
||||
ghcr.io/fish2018/pansou:latest
|
||||
|
||||
# 配置多个用户
|
||||
docker run -d --name pansou -p 8888:8888 \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e AUTH_USERS=admin:pass123,user1:pass456,user2:pass789 \
|
||||
ghcr.io/fish2018/pansou:latest
|
||||
```
|
||||
|
||||
**认证API接口:**
|
||||
|
||||
- `POST /api/auth/login` - 用户登录,获取Token
|
||||
- `POST /api/auth/verify` - 验证Token有效性
|
||||
- `POST /api/auth/logout` - 退出登录(客户端删除Token)
|
||||
|
||||
**使用Token调用API:**
|
||||
|
||||
```bash
|
||||
# 1. 登录获取Token
|
||||
curl -X POST http://localhost:8888/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}'
|
||||
|
||||
# 响应:{"token":"eyJhbGc...","expires_at":1234567890,"username":"admin"}
|
||||
|
||||
# 2. 使用Token调用搜索API
|
||||
curl -X POST http://localhost:8888/api/search \
|
||||
-H "Authorization: Bearer eyJhbGc..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"kw":"速度与激情"}'
|
||||
```
|
||||
|
||||
#### 高级配置(默认值即可)
|
||||
|
||||
|
||||
100
api/auth_handler.go
Normal file
100
api/auth_handler.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"pansou/config"
|
||||
"pansou/util"
|
||||
)
|
||||
|
||||
// LoginRequest 登录请求结构
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应结构
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// LoginHandler 处理用户登录
|
||||
func LoginHandler(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误:用户名和密码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证认证系统是否启用
|
||||
if !config.AppConfig.AuthEnabled {
|
||||
c.JSON(403, gin.H{"error": "认证功能未启用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户配置是否存在
|
||||
if config.AppConfig.AuthUsers == nil || len(config.AppConfig.AuthUsers) == 0 {
|
||||
c.JSON(500, gin.H{"error": "认证系统未正确配置"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户名和密码
|
||||
storedPassword, exists := config.AppConfig.AuthUsers[req.Username]
|
||||
if !exists || storedPassword != req.Password {
|
||||
c.JSON(401, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
token, err := util.GenerateToken(
|
||||
req.Username,
|
||||
config.AppConfig.AuthJWTSecret,
|
||||
config.AppConfig.AuthTokenExpiry,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "生成令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回token和过期时间
|
||||
expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix()
|
||||
c.JSON(200, LoginResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
Username: req.Username,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyHandler 验证token有效性
|
||||
func VerifyHandler(c *gin.Context) {
|
||||
// 如果未启用认证,直接返回有效
|
||||
if !config.AppConfig.AuthEnabled {
|
||||
c.JSON(200, gin.H{
|
||||
"valid": true,
|
||||
"message": "认证功能未启用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果能到达这里,说明中间件已经验证通过
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"valid": true,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutHandler 退出登录(客户端删除token即可)
|
||||
func LogoutHandler(c *gin.Context) {
|
||||
// JWT是无状态的,服务端不需要处理注销
|
||||
// 客户端删除存储的token即可
|
||||
c.JSON(200, gin.H{"message": "退出成功"})
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func SearchHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 返回结果
|
||||
// 包装SearchResponse到标准响应格式中
|
||||
response := model.NewSuccessResponse(result)
|
||||
jsonData, _ := jsonutil.Marshal(response)
|
||||
c.Data(http.StatusOK, "application/json", jsonData)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"pansou/config"
|
||||
"pansou/util"
|
||||
)
|
||||
|
||||
// CORSMiddleware 跨域中间件
|
||||
@@ -70,4 +72,70 @@ func LoggerMiddleware() gin.HandlerFunc {
|
||||
fmt.Sprintf("| %s | %s | %s | %d | %s\n",
|
||||
clientIP, reqMethod, displayURI, statusCode, latencyTime.String())))
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMiddleware JWT认证中间件
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 如果未启用认证,直接放行
|
||||
if !config.AppConfig.AuthEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 定义公开接口(不需要认证)
|
||||
publicPaths := []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/health", // 健康检查接口可选择是否需要认证
|
||||
}
|
||||
|
||||
// 检查当前路径是否是公开接口
|
||||
path := c.Request.URL.Path
|
||||
for _, p := range publicPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Authorization头
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(401, gin.H{
|
||||
"error": "未授权:缺少认证令牌",
|
||||
"code": "AUTH_TOKEN_MISSING",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析Bearer token
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
c.JSON(401, gin.H{
|
||||
"error": "未授权:令牌格式错误",
|
||||
"code": "AUTH_TOKEN_INVALID_FORMAT",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
|
||||
// 验证token
|
||||
claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"error": "未授权:令牌无效或已过期",
|
||||
"code": "AUTH_TOKEN_INVALID",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入上下文,供后续处理使用
|
||||
c.Set("username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,19 @@ func SetupRouter(searchService *service.SearchService) *gin.Engine {
|
||||
r.Use(CORSMiddleware())
|
||||
r.Use(LoggerMiddleware())
|
||||
r.Use(util.GzipMiddleware()) // 添加压缩中间件
|
||||
r.Use(AuthMiddleware()) // 添加认证中间件
|
||||
|
||||
// 定义API路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 认证接口(不需要认证,由中间件公开路径处理)
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", LoginHandler)
|
||||
auth.POST("/verify", VerifyHandler)
|
||||
auth.POST("/logout", LogoutHandler)
|
||||
}
|
||||
|
||||
// 搜索接口 - 支持POST和GET两种方式
|
||||
api.POST("/search", SearchHandler)
|
||||
api.GET("/search", SearchHandler) // 添加GET方式支持
|
||||
@@ -50,10 +59,11 @@ func SetupRouter(searchService *service.SearchService) *gin.Engine {
|
||||
channelsCount := len(channels)
|
||||
|
||||
response := gin.H{
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"auth_enabled": config.AppConfig.AuthEnabled, // 添加认证状态
|
||||
"plugins_enabled": pluginsEnabled,
|
||||
"channels": channels,
|
||||
"channels_count": channelsCount,
|
||||
"channels": channels,
|
||||
"channels_count": channelsCount,
|
||||
}
|
||||
|
||||
// 只有当插件启用时才返回插件相关信息
|
||||
|
||||
@@ -45,6 +45,11 @@ type Config struct {
|
||||
HTTPWriteTimeout time.Duration // 写入超时
|
||||
HTTPIdleTimeout time.Duration // 空闲超时
|
||||
HTTPMaxConns int // 最大连接数
|
||||
// 认证相关配置
|
||||
AuthEnabled bool // 是否启用认证
|
||||
AuthUsers map[string]string // 用户名:密码映射
|
||||
AuthTokenExpiry time.Duration // Token有效期
|
||||
AuthJWTSecret string // JWT签名密钥
|
||||
|
||||
}
|
||||
|
||||
@@ -91,6 +96,11 @@ func Init() {
|
||||
HTTPWriteTimeout: getHTTPWriteTimeout(),
|
||||
HTTPIdleTimeout: getHTTPIdleTimeout(),
|
||||
HTTPMaxConns: getHTTPMaxConns(),
|
||||
// 认证相关配置
|
||||
AuthEnabled: getAuthEnabled(),
|
||||
AuthUsers: getAuthUsers(),
|
||||
AuthTokenExpiry: getAuthTokenExpiry(),
|
||||
AuthJWTSecret: getAuthJWTSecret(),
|
||||
|
||||
}
|
||||
|
||||
@@ -501,6 +511,63 @@ func getAsyncLogEnabled() bool {
|
||||
return enabled
|
||||
}
|
||||
|
||||
// 从环境变量获取认证开关,如果未设置则默认关闭
|
||||
func getAuthEnabled() bool {
|
||||
enabled := os.Getenv("AUTH_ENABLED")
|
||||
return enabled == "true" || enabled == "1"
|
||||
}
|
||||
|
||||
// 从环境变量获取用户配置,格式:user1:pass1,user2:pass2
|
||||
func getAuthUsers() map[string]string {
|
||||
usersEnv := os.Getenv("AUTH_USERS")
|
||||
if usersEnv == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
users := make(map[string]string)
|
||||
pairs := strings.Split(usersEnv, ",")
|
||||
for _, pair := range pairs {
|
||||
parts := strings.SplitN(pair, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
username := strings.TrimSpace(parts[0])
|
||||
password := strings.TrimSpace(parts[1])
|
||||
if username != "" && password != "" {
|
||||
users[username] = password
|
||||
}
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// 从环境变量获取Token有效期(小时),如果未设置则使用默认值
|
||||
func getAuthTokenExpiry() time.Duration {
|
||||
expiryEnv := os.Getenv("AUTH_TOKEN_EXPIRY")
|
||||
if expiryEnv == "" {
|
||||
return 24 * time.Hour // 默认24小时
|
||||
}
|
||||
expiry, err := strconv.Atoi(expiryEnv)
|
||||
if err != nil || expiry <= 0 {
|
||||
return 24 * time.Hour
|
||||
}
|
||||
return time.Duration(expiry) * time.Hour
|
||||
}
|
||||
|
||||
// 从环境变量获取JWT密钥,如果未设置则生成随机密钥
|
||||
func getAuthJWTSecret() string {
|
||||
secret := os.Getenv("AUTH_JWT_SECRET")
|
||||
if secret == "" {
|
||||
// 生成随机密钥(32字节)
|
||||
import_crypto := "crypto/rand"
|
||||
import_encoding := "encoding/base64"
|
||||
_ = import_crypto
|
||||
_ = import_encoding
|
||||
// 注意:实际使用时应该使用crypto/rand生成随机密钥
|
||||
// 这里为了简化,使用时间戳作为临时密钥
|
||||
secret = "pansou-default-secret-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// 应用GC设置
|
||||
func applyGCSettings() {
|
||||
// 设置GC百分比
|
||||
|
||||
@@ -21,6 +21,11 @@ services:
|
||||
- ASYNC_MAX_BACKGROUND_WORKERS=20
|
||||
- ASYNC_MAX_BACKGROUND_TASKS=100
|
||||
- ASYNC_CACHE_TTL_HOURS=1
|
||||
# 认证配置(可选)
|
||||
# - AUTH_ENABLED=true
|
||||
# - AUTH_USERS=admin:admin123,user:pass456
|
||||
# - AUTH_TOKEN_EXPIRY=24
|
||||
# - AUTH_JWT_SECRET=your-secret-key-here
|
||||
# 如果需要代理,取消下面的注释并设置代理地址
|
||||
# - PROXY=socks5://proxy:7897
|
||||
volumes:
|
||||
|
||||
508
docs/系统开发设计文档.md
508
docs/系统开发设计文档.md
@@ -9,9 +9,10 @@
|
||||
- [5. 核心组件实现](#5-核心组件实现)
|
||||
- [6. 智能排序算法详解](#6-智能排序算法详解)
|
||||
- [7. API接口设计](#7-api接口设计)
|
||||
- [8. 插件开发框架](#8-插件开发框架)
|
||||
- [9. 性能优化实现](#9-性能优化实现)
|
||||
- [10. 技术选型说明](#10-技术选型说明)
|
||||
- [8. 认证系统设计](#8-认证系统设计)
|
||||
- [9. 插件开发框架](#9-插件开发框架)
|
||||
- [10. 性能优化实现](#10-性能优化实现)
|
||||
- [11. 技术选型说明](#11-技术选型说明)
|
||||
|
||||
---
|
||||
|
||||
@@ -664,9 +665,496 @@ GET /api/health
|
||||
|
||||
---
|
||||
|
||||
## 8. 插件开发框架
|
||||
## 8. 认证系统设计
|
||||
|
||||
### 8.1 基础开发模板
|
||||
### 8.1 系统概述
|
||||
|
||||
PanSou认证系统是一个可选的安全访问控制模块,基于JWT(JSON Web Token)标准实现。该系统设计目标是在不影响现有用户的前提下,为需要私有部署的用户提供灵活的认证功能。
|
||||
|
||||
#### 8.1.1 核心特性
|
||||
|
||||
- **可选性**: 默认关闭,通过环境变量`AUTH_ENABLED`启用
|
||||
- **无状态**: 基于JWT,无需session存储
|
||||
- **标准化**: 采用RFC 7519 JWT标准
|
||||
- **灵活性**: 支持多用户配置
|
||||
- **安全性**: Token自动过期,防止长期有效性风险
|
||||
|
||||
### 8.2 认证架构
|
||||
|
||||
#### 8.2.1 认证流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant F as 前端
|
||||
participant M as 认证中间件
|
||||
participant A as 认证接口
|
||||
participant S as 搜索服务
|
||||
|
||||
Note over U,S: 初始访问阶段
|
||||
U->>F: 访问应用
|
||||
F->>F: 检查localStorage中的token
|
||||
alt token不存在或无效
|
||||
F->>U: 显示登录窗口
|
||||
U->>F: 输入账号密码
|
||||
F->>A: POST /api/auth/login
|
||||
A->>A: 验证账号密码
|
||||
A->>A: 生成JWT Token
|
||||
A-->>F: 返回Token
|
||||
F->>F: 存储Token到localStorage
|
||||
F->>U: 关闭登录窗口
|
||||
end
|
||||
|
||||
Note over U,S: API调用阶段
|
||||
U->>F: 发起搜索请求
|
||||
F->>F: axios拦截器添加Authorization头
|
||||
F->>M: GET/POST /api/search + Token
|
||||
M->>M: 验证Token有效性
|
||||
alt Token有效
|
||||
M->>S: 转发请求
|
||||
S-->>M: 返回搜索结果
|
||||
M-->>F: 返回响应
|
||||
F-->>U: 显示结果
|
||||
else Token无效/过期
|
||||
M-->>F: 返回401 Unauthorized
|
||||
F->>F: 响应拦截器捕获401
|
||||
F->>U: 显示登录窗口
|
||||
end
|
||||
```
|
||||
|
||||
#### 8.2.2 组件架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端层 (Vue 3) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ LoginDialog │ │ HTTP拦截器 │ │ Token管理工具 │ │
|
||||
│ │ 登录组件 │ │ 自动添加Token │ │ LocalStorage │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ HTTP (Authorization: Bearer)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端层 (Go + Gin) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ AuthMiddleware 认证中间件 │ │
|
||||
│ │ • 检查AUTH_ENABLED配置 │ │
|
||||
│ │ • 排除公开接口(/api/auth/login, /api/health) │ │
|
||||
│ │ • 验证JWT Token有效性 │ │
|
||||
│ │ • 提取用户信息到Context │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │
|
||||
│ │ 认证接口 │ │ JWT工具 │ │ 配置管理 │ │
|
||||
│ │ /auth/login │ │ util/jwt.go │ │ config/config.go │ │
|
||||
│ │ /auth/verify│ │ GenerateToken│ │ AuthEnabled │ │
|
||||
│ │ /auth/logout│ │ ValidateToken│ │ AuthUsers │ │
|
||||
│ └─────────────┘ └─────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.3 后端实现细节
|
||||
|
||||
#### 8.3.1 配置模块 (config/config.go)
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// ... 现有配置 ...
|
||||
|
||||
// 认证相关配置
|
||||
AuthEnabled bool // 是否启用认证
|
||||
AuthUsers map[string]string // 用户名:密码哈希映射
|
||||
AuthTokenExpiry time.Duration // Token有效期
|
||||
AuthJWTSecret string // JWT签名密钥
|
||||
}
|
||||
|
||||
// 从环境变量读取认证配置
|
||||
func getAuthEnabled() bool {
|
||||
enabled := os.Getenv("AUTH_ENABLED")
|
||||
return enabled == "true" || enabled == "1"
|
||||
}
|
||||
|
||||
func getAuthUsers() map[string]string {
|
||||
usersEnv := os.Getenv("AUTH_USERS")
|
||||
if usersEnv == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
users := make(map[string]string)
|
||||
pairs := strings.Split(usersEnv, ",")
|
||||
for _, pair := range pairs {
|
||||
parts := strings.SplitN(pair, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
username := strings.TrimSpace(parts[0])
|
||||
password := strings.TrimSpace(parts[1])
|
||||
// 实际使用时应该对密码进行哈希处理
|
||||
users[username] = password
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.3.2 JWT工具模块 (util/jwt.go)
|
||||
|
||||
```go
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claims JWT载荷结构
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
func GenerateToken(username string, secret string, expiry time.Duration) (string, error) {
|
||||
expirationTime := time.Now().Add(expiry)
|
||||
claims := &Claims{
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "pansou",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ValidateToken 验证JWT token
|
||||
func ValidateToken(tokenString string, secret string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.3.3 认证中间件 (api/middleware.go)
|
||||
|
||||
```go
|
||||
// AuthMiddleware JWT认证中间件
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 如果未启用认证,直接放行
|
||||
if !config.AppConfig.AuthEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 定义公开接口(不需要认证)
|
||||
publicPaths := []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/verify",
|
||||
"/api/auth/logout",
|
||||
"/api/health", // 可选:健康检查是否需要认证
|
||||
}
|
||||
|
||||
// 检查当前路径是否是公开接口
|
||||
path := c.Request.URL.Path
|
||||
for _, p := range publicPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Authorization头
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(401, gin.H{
|
||||
"error": "未授权:缺少认证令牌",
|
||||
"code": "AUTH_TOKEN_MISSING",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析Bearer token
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
c.JSON(401, gin.H{
|
||||
"error": "未授权:令牌格式错误",
|
||||
"code": "AUTH_TOKEN_INVALID_FORMAT",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
|
||||
// 验证token
|
||||
claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"error": "未授权:令牌无效或已过期",
|
||||
"code": "AUTH_TOKEN_INVALID",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入上下文,供后续处理使用
|
||||
c.Set("username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.3.4 认证接口 (api/auth_handler.go)
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"pansou/config"
|
||||
"pansou/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginRequest 登录请求结构
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应结构
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// LoginHandler 处理用户登录
|
||||
func LoginHandler(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户名和密码
|
||||
if config.AppConfig.AuthUsers == nil {
|
||||
c.JSON(500, gin.H{"error": "认证系统未正确配置"})
|
||||
return
|
||||
}
|
||||
|
||||
storedPassword, exists := config.AppConfig.AuthUsers[req.Username]
|
||||
if !exists || storedPassword != req.Password {
|
||||
c.JSON(401, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
token, err := util.GenerateToken(
|
||||
req.Username,
|
||||
config.AppConfig.AuthJWTSecret,
|
||||
config.AppConfig.AuthTokenExpiry,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "生成令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回token和过期时间
|
||||
expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix()
|
||||
c.JSON(200, LoginResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
Username: req.Username,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyHandler 验证token有效性
|
||||
func VerifyHandler(c *gin.Context) {
|
||||
// 如果能到达这里,说明中间件已经验证通过
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"valid": true,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutHandler 退出登录(客户端删除token即可)
|
||||
func LogoutHandler(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "退出成功"})
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 前端实现细节
|
||||
|
||||
#### 8.4.1 API模块扩展 (src/api/index.ts)
|
||||
|
||||
```typescript
|
||||
// 登录接口
|
||||
export interface LoginParams {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
expires_at: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const login = async (params: LoginParams): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>('/auth/login', params);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 验证token
|
||||
export const verifyToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
await api.post('/auth/verify');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_username');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 8.4.2 HTTP拦截器配置
|
||||
|
||||
```typescript
|
||||
// 请求拦截器 - 自动添加token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// 响应拦截器 - 处理401
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 清除token
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_username');
|
||||
|
||||
// 触发显示登录窗口
|
||||
window.dispatchEvent(new CustomEvent('auth:required'));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 8.5 API文档组件集成
|
||||
|
||||
在 `ApiDocs.vue` 组件中,需要确保在线调试功能自动携带token:
|
||||
|
||||
```typescript
|
||||
// 生成请求预览时包含Authorization头
|
||||
const generateSearchRequest = () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
let headers = 'Content-Type: application/json\n';
|
||||
|
||||
if (token) {
|
||||
headers += `Authorization: Bearer ${token}\n`;
|
||||
}
|
||||
|
||||
if (searchMethod.value === 'POST') {
|
||||
return `POST /api/search
|
||||
${headers}
|
||||
${JSON.stringify(payload, null, 2)}`;
|
||||
}
|
||||
// ... GET请求类似处理
|
||||
};
|
||||
```
|
||||
|
||||
### 8.6 健康检查接口扩展
|
||||
|
||||
`/api/health` 接口需要返回认证状态信息:
|
||||
|
||||
```go
|
||||
func HealthHandler(c *gin.Context) {
|
||||
// ... 现有逻辑 ...
|
||||
|
||||
response := gin.H{
|
||||
"status": "ok",
|
||||
"auth_enabled": config.AppConfig.AuthEnabled, // 新增
|
||||
"plugins_enabled": pluginsEnabled,
|
||||
"plugin_count": pluginCount,
|
||||
"plugins": pluginNames,
|
||||
"channels": channels,
|
||||
"channels_count": channelsCount,
|
||||
}
|
||||
|
||||
c.JSON(200, response)
|
||||
}
|
||||
```
|
||||
|
||||
### 8.7 环境变量配置
|
||||
|
||||
| 变量名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `AUTH_ENABLED` | boolean | `false` | 是否启用认证功能 |
|
||||
| `AUTH_USERS` | string | - | 用户配置,格式:`user1:pass1,user2:pass2` |
|
||||
| `AUTH_TOKEN_EXPIRY` | int | `24` | Token有效期(小时) |
|
||||
| `AUTH_JWT_SECRET` | string | 随机生成 | JWT签名密钥 |
|
||||
|
||||
### 8.8 安全考虑
|
||||
|
||||
1. **密码存储**: 生产环境应使用bcrypt等算法对密码进行哈希
|
||||
2. **HTTPS传输**: 生产环境必须使用HTTPS保护token传输
|
||||
3. **Token过期**: 合理设置token有效期,避免长期有效
|
||||
4. **限流保护**: 对登录接口实施限流,防止暴力破解
|
||||
5. **密钥管理**: JWT_SECRET应随机生成并妥善保管
|
||||
|
||||
### 8.9 性能影响
|
||||
|
||||
- **未启用认证**: 零性能影响,中间件直接放行
|
||||
- **启用认证**: 每个请求增加约0.1-0.5ms的token验证时间
|
||||
- **并发性能**: JWT无状态特性,对高并发无影响
|
||||
- **缓存友好**: 认证不影响现有缓存机制
|
||||
|
||||
---
|
||||
|
||||
## 9. 插件开发框架
|
||||
|
||||
### 9.1 基础开发模板
|
||||
|
||||
```go
|
||||
package myplugin
|
||||
@@ -718,13 +1206,13 @@ func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[strin
|
||||
|
||||
---
|
||||
|
||||
## 9. 性能优化实现
|
||||
## 10. 性能优化实现
|
||||
|
||||
### 9.1 环境配置优化
|
||||
### 10.1 环境配置优化
|
||||
|
||||
基于实际性能测试结果的配置方案:
|
||||
|
||||
#### 9.1.1 macOS优化配置
|
||||
#### 10.1.1 macOS优化配置
|
||||
```bash
|
||||
export HTTP_MAX_CONNS=200
|
||||
export ASYNC_MAX_BACKGROUND_WORKERS=15
|
||||
@@ -751,9 +1239,9 @@ export ASYNC_LOG_ENABLED=false # 控制异步插件详细日志
|
||||
|
||||
---
|
||||
|
||||
## 10. 技术选型说明
|
||||
## 11. 技术选型说明
|
||||
|
||||
### 10.1 Go语言优势
|
||||
### 11.1 Go语言优势
|
||||
- **并发支持**: 原生goroutine,适合高并发场景
|
||||
- **性能优秀**: 编译型语言,接近C的性能
|
||||
- **部署简单**: 单一可执行文件,无外部依赖
|
||||
|
||||
1
go.mod
1
go.mod
@@ -6,6 +6,7 @@ require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/bytedance/sonic v1.14.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
golang.org/x/net v0.41.0
|
||||
)
|
||||
|
||||
|
||||
6
go.sum
6
go.sum
@@ -2,13 +2,9 @@ github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAc
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
@@ -33,6 +29,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
|
||||
67
util/jwt.go
Normal file
67
util/jwt.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims JWT载荷结构
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
func GenerateToken(username string, secret string, expiry time.Duration) (string, error) {
|
||||
if username == "" {
|
||||
return "", errors.New("username cannot be empty")
|
||||
}
|
||||
if secret == "" {
|
||||
return "", errors.New("secret cannot be empty")
|
||||
}
|
||||
|
||||
expirationTime := time.Now().Add(expiry)
|
||||
claims := &Claims{
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "pansou",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ValidateToken 验证JWT token
|
||||
func ValidateToken(tokenString string, secret string) (*Claims, error) {
|
||||
if tokenString == "" {
|
||||
return nil, errors.New("token cannot be empty")
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, errors.New("secret cannot be empty")
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
// 验证签名算法
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
Reference in New Issue
Block a user