e41085a878
会话安全(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>
119 lines
4.8 KiB
Go
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)
|
|
}
|