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>
339 lines
10 KiB
Go
339 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"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"
|
|
)
|
|
|
|
func init() {
|
|
gin.SetMode(gin.TestMode)
|
|
testutil.InitConfig()
|
|
}
|
|
|
|
func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "AUTHTEST")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
testutil.CreateTestUser(db, shop.ID, "disabled", "password123", "operator")
|
|
// 禁用该用户
|
|
db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND shop_id = ?", shop.ID)
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
r.POST("/api/v1/auth/refresh", h.Refresh)
|
|
return r, nil
|
|
}
|
|
|
|
func TestAuthHandler_Login_Success(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "AH001")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
body := map[string]string{
|
|
"shop_code": "AH001",
|
|
"username": "admin",
|
|
"password": "password123",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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.NotEmpty(t, data["access_token"])
|
|
assert.NotEmpty(t, data["refresh_token"])
|
|
}
|
|
|
|
func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "AH002")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
body := map[string]string{
|
|
"shop_code": "AH002",
|
|
"username": "admin",
|
|
"password": "wrongpassword",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestAuthHandler_Login_MissingFields(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
// 缺少必填字段
|
|
body := map[string]string{
|
|
"shop_code": "AH003",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestAuthHandler_Refresh_Success(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "AH004")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
r.POST("/api/v1/auth/refresh", h.Refresh)
|
|
|
|
// 先登录获取 token
|
|
loginBody := map[string]string{
|
|
"shop_code": "AH004",
|
|
"username": "admin",
|
|
"password": "password123",
|
|
}
|
|
loginBytes, _ := json.Marshal(loginBody)
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(loginBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
var loginResp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &loginResp)
|
|
data := loginResp["data"].(map[string]interface{})
|
|
refreshToken := data["refresh_token"].(string)
|
|
|
|
// 刷新 token
|
|
refreshBody := map[string]string{"refresh_token": refreshToken}
|
|
refreshBytes, _ := json.Marshal(refreshBody)
|
|
w2 := httptest.NewRecorder()
|
|
req2, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(refreshBytes))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
assert.Equal(t, http.StatusOK, w2.Code)
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w2.Body.Bytes(), &resp)
|
|
newData := resp["data"].(map[string]interface{})
|
|
assert.NotEmpty(t, newData["access_token"])
|
|
}
|
|
|
|
func TestAuthHandler_Refresh_InvalidToken(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/refresh", h.Refresh)
|
|
|
|
body := map[string]string{"refresh_token": "invalid.token.here"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestAuthHandler_Login_DisabledUser(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "AH005")
|
|
user := testutil.CreateTestUser(db, shop.ID, "disabled_user", "password123", "operator")
|
|
db.Model(user).Update("is_active", false)
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
body := map[string]string{
|
|
"shop_code": "AH005",
|
|
"username": "disabled_user",
|
|
"password": "password123",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
testutil.CreateTestShop(db, "AH006")
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
body := map[string]string{
|
|
"shop_code": "NONEXISTENT",
|
|
"username": "admin",
|
|
"password": "password123",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer([]byte("{}")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestAuthHandler_Refresh_MissingToken(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/refresh", h.Refresh)
|
|
|
|
body := map[string]string{}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
// 缺少 refresh_token,应返回 4xx
|
|
assert.True(t, w.Code >= 400 && w.Code < 500)
|
|
}
|
|
|
|
func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "AH007")
|
|
testutil.CreateTestUser(db, shop.ID, "manager", "password123", "admin")
|
|
|
|
svc := service.NewAuthService(db)
|
|
h := NewAuthHandler(svc, nil)
|
|
r := gin.New()
|
|
r.POST("/api/v1/auth/login", h.Login)
|
|
|
|
body := map[string]string{
|
|
"shop_code": "AH007",
|
|
"username": "manager",
|
|
"password": "password123",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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{})
|
|
// token 不为空
|
|
assert.NotEmpty(t, data["access_token"])
|
|
assert.NotEmpty(t, data["refresh_token"])
|
|
// 包含 shop_id
|
|
assert.NotNil(t, data["shop_id"])
|
|
// 包含用户信息
|
|
userInfo, ok := data["user"].(map[string]interface{})
|
|
if ok {
|
|
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"])
|
|
}
|