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>
This commit is contained in:
@@ -11,7 +11,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
@@ -29,7 +32,7 @@ func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) {
|
||||
db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND shop_id = ?", shop.ID)
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
@@ -43,7 +46,7 @@ func TestAuthHandler_Login_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -74,7 +77,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -96,7 +99,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
func TestAuthHandler_Login_MissingFields(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -120,7 +123,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
@@ -160,7 +163,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||
func TestAuthHandler_Refresh_InvalidToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
|
||||
@@ -181,7 +184,7 @@ func TestAuthHandler_Login_DisabledUser(t *testing.T) {
|
||||
db.Model(user).Update("is_active", false)
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -205,7 +208,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
||||
testutil.CreateTestShop(db, "AH006")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -227,7 +230,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
||||
func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -242,7 +245,7 @@ func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
||||
func TestAuthHandler_Refresh_MissingToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
|
||||
@@ -263,7 +266,7 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "manager", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -294,3 +297,42 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
||||
assert.Equal(t, "manager", userInfo["username"])
|
||||
}
|
||||
}
|
||||
|
||||
// #8 心跳 /auth/ping 回带授权概况,客户端据此免去单独轮询 /license/info。
|
||||
func TestAuthHandler_Ping_ReturnsLicense(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
priv, _, err := util.GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
config.C.License.Ed25519PrivateKey = priv
|
||||
|
||||
shop := testutil.CreateTestShop(db, "AHPING")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
authSvc := service.NewAuthService(db)
|
||||
// 登录触发首登自动 trial。
|
||||
_, _, err = authSvc.Login("AHPING", "admin", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
licSvc := service.NewLicenseService(db)
|
||||
h := NewAuthHandler(authSvc, licSvc)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/ping", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxShopID, shop.ID) // 模拟 JWT 中间件注入 shop_id
|
||||
h.Ping(c)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/auth/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Equal(t, true, data["ok"])
|
||||
lic, ok := data["license"].(map[string]interface{})
|
||||
require.True(t, ok, "心跳应回带 license 概况")
|
||||
assert.Equal(t, "trial", lic["type"])
|
||||
assert.NotEmpty(t, lic["phase"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user