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>
170 lines
5.9 KiB
Go
170 lines
5.9 KiB
Go
package service
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/wangjia/jiu/backend/config"
|
|
"github.com/wangjia/jiu/backend/internal/model"
|
|
"github.com/wangjia/jiu/backend/testutil"
|
|
)
|
|
|
|
// 同平台类超配额时踢掉最旧会话。
|
|
func TestLogin_PerClassQuota_KicksOldest(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS01")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
dev := DeviceInfo{Platform: "windows"}
|
|
p1, _, err := svc.Login("SESS01", "admin", "password123", dev)
|
|
require.NoError(t, err)
|
|
p2, _, err := svc.Login("SESS01", "admin", "password123", dev)
|
|
require.NoError(t, err)
|
|
// 配额 2,第 3 次桌面端登录应踢掉最旧(p1)
|
|
_, _, err = svc.Login("SESS01", "admin", "password123", dev)
|
|
require.NoError(t, err)
|
|
|
|
// 共 2 个未撤销会话
|
|
var active int64
|
|
db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active)
|
|
assert.Equal(t, int64(2), active)
|
|
|
|
// p1 的 refresh 应失败(会话被踢)
|
|
_, err = svc.RefreshTokens(p1.RefreshToken)
|
|
assert.ErrorIs(t, err, ErrSessionRevoked)
|
|
// p2 仍可续期
|
|
_, err = svc.RefreshTokens(p2.RefreshToken)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
// 移动端与桌面端配额互不影响。
|
|
func TestLogin_QuotaIsolatedByClass(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS02")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
_, _, err := svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
require.NoError(t, err)
|
|
_, _, err = svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "android"})
|
|
require.NoError(t, err)
|
|
|
|
var active int64
|
|
db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active)
|
|
assert.Equal(t, int64(2), active) // 桌面 1 + 移动 1,都没被踢
|
|
}
|
|
|
|
// 配额为 0 的平台拒绝登录(如禁 web)。
|
|
func TestLogin_PlatformDisabled(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS03")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
old := config.C.Session.LimitWeb
|
|
config.C.Session.LimitWeb = 0
|
|
defer func() { config.C.Session.LimitWeb = old }()
|
|
|
|
_, _, err := svc.Login("SESS03", "admin", "password123", DeviceInfo{Platform: "web"})
|
|
assert.ErrorIs(t, err, ErrPlatformNotAllowed)
|
|
}
|
|
|
|
// 每店 session_policy 覆盖全局默认。
|
|
func TestLogin_PerShopPolicyOverride(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS04")
|
|
// 该店限桌面 1
|
|
db.Model(&model.Shop{}).Where("id = ?", shop.ID).
|
|
Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"desktop": float64(1)}})
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
p1, _, err := svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
require.NoError(t, err)
|
|
_, _, err = svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
require.NoError(t, err)
|
|
|
|
// 第 2 次登录应把 p1 踢掉(配额 1)
|
|
_, err = svc.RefreshTokens(p1.RefreshToken)
|
|
assert.ErrorIs(t, err, ErrSessionRevoked)
|
|
}
|
|
|
|
// 被禁用用户无法用 refresh token 续命(修复历史漏洞)。
|
|
func TestRefresh_DisabledUserRejected(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS05")
|
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
pair, _, err := svc.Login("SESS05", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
require.NoError(t, err)
|
|
|
|
db.Model(user).Update("is_active", false)
|
|
|
|
_, err = svc.RefreshTokens(pair.RefreshToken)
|
|
assert.ErrorIs(t, err, ErrUserInactive)
|
|
}
|
|
|
|
// 管理员强制下线后,该会话 token 无法续期。
|
|
func TestForceLogout_RevokesSession(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS06")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
pair, _, err := svc.Login("SESS06", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
require.NoError(t, err)
|
|
|
|
views, err := svc.ListSessions(shop.ID, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, views, 1)
|
|
assert.True(t, views[0].Online)
|
|
|
|
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, 0))
|
|
|
|
_, err = svc.RefreshTokens(pair.RefreshToken)
|
|
assert.ErrorIs(t, err, ErrSessionRevoked)
|
|
|
|
// 列表中不再出现
|
|
views, err = svc.ListSessions(shop.ID, "")
|
|
require.NoError(t, err)
|
|
assert.Len(t, views, 0)
|
|
}
|
|
|
|
// 强制下线跨店隔离:不能下线别店会话。
|
|
func TestForceLogout_TenantIsolation(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shopA := testutil.CreateTestShop(db, "SESS07A")
|
|
shopB := testutil.CreateTestShop(db, "SESS07B")
|
|
testutil.CreateTestUser(db, shopA.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
_, _, err := svc.Login("SESS07A", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
require.NoError(t, err)
|
|
views, _ := svc.ListSessions(shopA.ID, "")
|
|
require.Len(t, views, 1)
|
|
|
|
// 用 shopB 的 shopID 尝试下线 shopA 的会话 → 找不到
|
|
err = svc.ForceLogout(shopB.ID, views[0].ID, 0)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// 连续登录失败达到阈值后锁定。
|
|
func TestLogin_LockoutAfterFailures(t *testing.T) {
|
|
db := testutil.SetupTestDB()
|
|
shop := testutil.CreateTestShop(db, "SESS08")
|
|
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
|
svc := NewAuthService(db)
|
|
|
|
for i := 0; i < config.C.Session.MaxFailures; i++ {
|
|
_, _, err := svc.Login("SESS08", "admin", "wrong", DeviceInfo{Platform: "windows"})
|
|
assert.ErrorIs(t, err, ErrInvalidCredentials)
|
|
}
|
|
// 锁定后即便密码正确也被拒
|
|
_, _, err := svc.Login("SESS08", "admin", "password123", DeviceInfo{Platform: "windows"})
|
|
assert.ErrorIs(t, err, ErrTooManyAttempts)
|
|
}
|