Files
jiu/backend/internal/handler/session_security_test.go
wangjia e41085a878 feat(backend): 会话安全加固 + 授权实时 phase + 首次使用自动试用
会话安全(jti 轮换 / 重用检测 / 改密吊销 / 禁用即时下线 / 清理 / 失败登录落库):
- refresh token 轮换 jti + token-family 重用检测,旧 token 重放即吊销整条会话
- 改密码、停用用户即时吊销其全部活跃会话(revoked_by 审计)
- 中间件 session JOIN user 校验,禁用/删除用户带 token 请求返回 401 USER_DISABLED
- 新增 login_attempts 失败登录落库 + 会话保留期清理 goroutine

授权实时 phase + 心跳回带:
- LicenseGuard 改为按当前 DB 实时计算 phase(30s 每店缓存),续费/过期/被改 ~30s 内对写操作生效,无需重登
- /auth/ping 回带授权概况(ShopInfoView,与 /license/info 同构),客户端一次心跳即刷新横幅/门禁

首次使用自动试用 + code-review 修复:
- 门店首次登录/续期无有效授权时自动签发 30 天 trial(快路径无锁 Count,仅首用走 FOR UPDATE 事务)
- ShopInfo 区分「确无授权」与瞬时 DB 错误,避免误降级
- trial 签发后改为在事务提交后再失效 phase 缓存(修复早于提交的竞态)
- 存量无 sid token 续期纳入显式上限,legacy 会话不再游离于并发配额之外

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:04 +08:00

119 lines
4.8 KiB
Go

package handler
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/testutil"
)
// setupSessionSecurityRouter 挂 JWT 中间件 + 用户管理路由 + 一个受保护 GET,
// 用于验证「改密/禁用即时下线」与中间件对 is_active 的兜底校验。
func setupSessionSecurityRouter(db *gorm.DB) *gin.Engine {
userH := NewUserHandler(db)
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT(db))
api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
users := api.Group("/users")
users.PUT("/:id", userH.Update)
users.PUT("/:id/reset-password", userH.ResetPassword)
return r
}
// #2 管理员重置某用户密码 → 该用户全部活跃会话立即失效。
func TestResetPassword_RevokesUserSessions(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SEC01")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
r := setupSessionSecurityRouter(db)
svc := service.NewAuthService(db)
// 目标用户登录,拿到带 sid 的 access token(建出真实会话行)。
pair, _, err := svc.Login("SEC01", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
require.NoError(t, err)
targetToken := pair.AccessToken
// 改密前:目标 token 可用。
require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code)
// 管理员重置目标用户密码。
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "PUT", "/api/v1/users/2/reset-password", adminToken, jsonBody("password", "newpass123"))
require.Equal(t, http.StatusOK, w.Code)
// 改密后:目标 token 立即 401 SESSION_REVOKED。
w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"])
// 会话审计字段。
var sess model.UserSession
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
assert.Equal(t, "pwd_reset", sess.RevokedReason)
require.NotNil(t, sess.RevokedBy)
assert.Equal(t, admin.ID, *sess.RevokedBy)
}
// #3 管理员停用某用户 → 该用户活跃会话被吊销,原 token 立即 401。
func TestDisableUser_RevokesSessions(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SEC02")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
r := setupSessionSecurityRouter(db)
svc := service.NewAuthService(db)
pair, _, err := svc.Login("SEC02", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
require.NoError(t, err)
targetToken := pair.AccessToken
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "PUT", "/api/v1/users/2", adminToken, jsonBody("is_active", false))
require.Equal(t, http.StatusOK, w.Code)
w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"])
var sess model.UserSession
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
assert.Equal(t, "disabled", sess.RevokedReason)
}
// #3 兜底:直接改库把 is_active=0(不动会话)→ 中间件 JOIN 校验返回 401 USER_DISABLED。
func TestMiddleware_RejectsDisabledUserOnDirectDBFlip(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SEC03")
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
r := setupSessionSecurityRouter(db)
svc := service.NewAuthService(db)
pair, _, err := svc.Login("SEC03", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
require.NoError(t, err)
targetToken := pair.AccessToken
require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code)
// 绕过 handler 直接改库:会话仍为活跃(revoked_at 为空),仅 is_active=0。
require.NoError(t, db.Exec("UPDATE users SET is_active = 0 WHERE id = ?", target.ID).Error)
w := makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, "USER_DISABLED", parseResponse(w)["code"])
// 会话本身未被吊销,证明拦截来自中间件对 is_active 的兜底校验。
var sess model.UserSession
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
assert.Nil(t, sess.RevokedAt)
}