应对管理员访问凭证失守导致的数据外泄风险,新增三层防护: 审计日志(admin-only 可见,用户不可见) - 新增 append-only audit_logs 表(migration 180)+ 异步批量写入 + 保留期清理 - 审计中间件挂在 admin/user/auth/admin-payment 组认证之后:记录所有变更类 请求 + 白名单敏感读取(账号/代理导出、备份下载、admin/user API key 读取) - 请求头凭证首尾掩码;请求体 JSON 递归脱敏(api_key/password 等擦除,base_url 保留以便追责);非 JSON body 不入库 - 无单条删除;全量清空需现场 TOTP 校验、拒绝 admin API key、未启用 2FA 不允许, 清空后同步写入留痕记录 会话 IP/UA 绑定(默认开启,可在系统设置关闭) - JWT 携带 session id + IP/UA 指纹哈希;IP 或 UA 任一变化即撤销会话家族并要求 重新登录;旧 token 无指纹时放行以平滑升级 敏感操作 step-up 2FA(sudo 窗口 15 分钟) - 账号/代理导出、DB 备份创建/下载、S3 目标修改要求近期 TOTP 二次验证;admin API key 一律拒绝;前端 useStepUp 组合式 + TotpStepUpDialog 弹码后自动重试 - API key 查看按需求暂不加强管控 前端:新增 /admin/audit-logs 操作日志页面(筛选/详情/2FA 清空)、侧边栏入口、 step-up 弹窗接入导出与备份流程、安全设置项(绑定开关 + 日志保留天数)、zh/en i18n
316 lines
9.0 KiB
Go
316 lines
9.0 KiB
Go
//go:build unit
|
|
|
|
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// stubJWTUserRepo 实现 UserRepository 的最小子集,仅支持 GetByID。
|
|
type stubJWTUserRepo struct {
|
|
service.UserRepository
|
|
users map[int64]*service.User
|
|
}
|
|
|
|
func (r *stubJWTUserRepo) GetByID(_ context.Context, id int64) (*service.User, error) {
|
|
u, ok := r.users[id]
|
|
if !ok {
|
|
return nil, errors.New("user not found")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (r *stubJWTUserRepo) GetUserAvatar(_ context.Context, _ int64) (*service.UserAvatar, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *stubJWTUserRepo) UpdateUserLastActiveAt(_ context.Context, _ int64, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
type recordingActivityToucher struct {
|
|
userIDs []int64
|
|
}
|
|
|
|
func (r *recordingActivityToucher) TouchLastActiveForUser(_ context.Context, user *service.User) {
|
|
if user == nil {
|
|
return
|
|
}
|
|
r.userIDs = append(r.userIDs, user.ID)
|
|
}
|
|
|
|
// newJWTTestEnv 创建 JWT 认证中间件测试环境。
|
|
// 返回 gin.Engine(已注册 JWT 中间件)和 AuthService(用于生成 Token)。
|
|
func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := &config.Config{}
|
|
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
|
|
cfg.JWT.AccessTokenExpireMinutes = 60
|
|
|
|
userRepo := &stubJWTUserRepo{users: users}
|
|
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
|
|
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
|
mw := NewJWTAuthMiddleware(authSvc, userSvc, nil, nil)
|
|
|
|
r := gin.New()
|
|
r.Use(gin.HandlerFunc(mw))
|
|
r.GET("/protected", func(c *gin.Context) {
|
|
subject, _ := GetAuthSubjectFromContext(c)
|
|
role, _ := GetUserRoleFromContext(c)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user_id": subject.UserID,
|
|
"role": role,
|
|
})
|
|
})
|
|
return r, authSvc
|
|
}
|
|
|
|
func TestJWTAuth_ValidToken(t *testing.T) {
|
|
user := &service.User{
|
|
ID: 1,
|
|
Email: "test@example.com",
|
|
Role: "user",
|
|
Status: service.StatusActive,
|
|
Concurrency: 5,
|
|
TokenVersion: 1,
|
|
}
|
|
router, authSvc := newJWTTestEnv(map[int64]*service.User{1: user})
|
|
|
|
token, err := authSvc.GenerateToken(context.Background(), user)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, float64(1), body["user_id"])
|
|
require.Equal(t, "user", body["role"])
|
|
}
|
|
|
|
func TestJWTAuth_ValidToken_LowercaseBearer(t *testing.T) {
|
|
user := &service.User{
|
|
ID: 1,
|
|
Email: "test@example.com",
|
|
Role: "user",
|
|
Status: service.StatusActive,
|
|
Concurrency: 5,
|
|
TokenVersion: 1,
|
|
}
|
|
router, authSvc := newJWTTestEnv(map[int64]*service.User{1: user})
|
|
|
|
token, err := authSvc.GenerateToken(context.Background(), user)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "bearer "+token)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestJWTAuth_ValidToken_TouchesLastActive(t *testing.T) {
|
|
user := &service.User{
|
|
ID: 1,
|
|
Email: "test@example.com",
|
|
Role: "user",
|
|
Status: service.StatusActive,
|
|
Concurrency: 5,
|
|
TokenVersion: 1,
|
|
}
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := &config.Config{}
|
|
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
|
|
cfg.JWT.AccessTokenExpireMinutes = 60
|
|
|
|
userRepo := &stubJWTUserRepo{users: map[int64]*service.User{1: user}}
|
|
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
|
|
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
|
toucher := &recordingActivityToucher{}
|
|
|
|
r := gin.New()
|
|
r.Use(jwtAuth(authSvc, userSvc, toucher, nil, nil))
|
|
r.GET("/protected", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
token, err := authSvc.GenerateToken(context.Background(), user)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, []int64{1}, toucher.userIDs)
|
|
}
|
|
|
|
func TestJWTAuth_MissingAuthorizationHeader(t *testing.T) {
|
|
router, _ := newJWTTestEnv(nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "UNAUTHORIZED", body.Code)
|
|
}
|
|
|
|
func TestJWTAuth_InvalidHeaderFormat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
header string
|
|
}{
|
|
{"无Bearer前缀", "Token abc123"},
|
|
{"缺少空格分隔", "Bearerabc123"},
|
|
{"仅有单词", "abc123"},
|
|
}
|
|
router, _ := newJWTTestEnv(nil)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", tt.header)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "INVALID_AUTH_HEADER", body.Code)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJWTAuth_EmptyToken(t *testing.T) {
|
|
router, _ := newJWTTestEnv(nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer ")
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "EMPTY_TOKEN", body.Code)
|
|
}
|
|
|
|
func TestJWTAuth_TamperedToken(t *testing.T) {
|
|
router, _ := newJWTTestEnv(nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.invalid_signature")
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "INVALID_TOKEN", body.Code)
|
|
}
|
|
|
|
func TestJWTAuth_UserNotFound(t *testing.T) {
|
|
// 使用 user ID=1 的 token,但 repo 中没有该用户
|
|
fakeUser := &service.User{
|
|
ID: 999,
|
|
Email: "ghost@example.com",
|
|
Role: "user",
|
|
Status: service.StatusActive,
|
|
TokenVersion: 1,
|
|
}
|
|
// 创建环境时不注入此用户,这样 GetByID 会失败
|
|
router, authSvc := newJWTTestEnv(map[int64]*service.User{})
|
|
|
|
token, err := authSvc.GenerateToken(context.Background(), fakeUser)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "USER_NOT_FOUND", body.Code)
|
|
}
|
|
|
|
func TestJWTAuth_UserInactive(t *testing.T) {
|
|
user := &service.User{
|
|
ID: 1,
|
|
Email: "disabled@example.com",
|
|
Role: "user",
|
|
Status: service.StatusDisabled,
|
|
TokenVersion: 1,
|
|
}
|
|
router, authSvc := newJWTTestEnv(map[int64]*service.User{1: user})
|
|
|
|
token, err := authSvc.GenerateToken(context.Background(), user)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "USER_INACTIVE", body.Code)
|
|
}
|
|
|
|
func TestJWTAuth_TokenVersionMismatch(t *testing.T) {
|
|
// Token 生成时 TokenVersion=1,但数据库中用户已更新为 TokenVersion=2(密码修改)
|
|
userForToken := &service.User{
|
|
ID: 1,
|
|
Email: "test@example.com",
|
|
Role: "user",
|
|
Status: service.StatusActive,
|
|
TokenVersion: 1,
|
|
}
|
|
userInDB := &service.User{
|
|
ID: 1,
|
|
Email: "test@example.com",
|
|
Role: "user",
|
|
Status: service.StatusActive,
|
|
TokenVersion: 2, // 密码修改后版本递增
|
|
}
|
|
router, authSvc := newJWTTestEnv(map[int64]*service.User{1: userInDB})
|
|
|
|
token, err := authSvc.GenerateToken(context.Background(), userForToken)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
var body ErrorResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "TOKEN_REVOKED", body.Code)
|
|
}
|