7bbc944ae2
Deploy Server / release-deploy-server (push) Successful in 51s
服务端安全加固:多维限流(按 IP/按门店)+ 敏感接口独立速率上限抵御 DDoS/刷接口; 登录暴力破解新增按来源 IP 锁定;反代后正确识别真实客户端 IP; 门店 custom_fields 轻量配置(录入默认值)透传保存。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
214 lines
7.0 KiB
Go
214 lines
7.0 KiB
Go
package service
|
||
|
||
import (
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
|
||
"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 TestAuthService_Login_Success(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "HOTEL001")
|
||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||
|
||
svc := NewAuthService(db)
|
||
pair, user, err := svc.Login("HOTEL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, pair)
|
||
require.NotNil(t, user)
|
||
assert.NotEmpty(t, pair.AccessToken)
|
||
assert.NotEmpty(t, pair.RefreshToken)
|
||
assert.Equal(t, "admin", user.Username)
|
||
}
|
||
|
||
func TestAuthService_Login_AutoTrialOnFirstUse(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
|
||
shop := testutil.CreateTestShop(db, "TRIAL001")
|
||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||
|
||
// 前置:门店「未激活」——无任何授权行
|
||
var before int64
|
||
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&before)
|
||
require.EqualValues(t, 0, before)
|
||
|
||
svc := NewAuthService(db)
|
||
|
||
// 首次登录 → 自动签发 30 天 trial
|
||
_, _, err := svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||
require.NoError(t, err)
|
||
|
||
var lic model.License
|
||
require.NoError(t, db.Where("shop_id = ? AND is_active = 1", shop.ID).First(&lic).Error)
|
||
assert.Equal(t, "trial", lic.Type)
|
||
assert.True(t, lic.IsActive)
|
||
require.NotNil(t, lic.ExpiresAt)
|
||
days := time.Until(*lic.ExpiresAt).Hours() / 24
|
||
assert.InDelta(t, 30, days, 1, "试用期应约为 30 天")
|
||
|
||
// 再次登录不重复发放
|
||
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||
require.NoError(t, err)
|
||
var count int64
|
||
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&count)
|
||
assert.EqualValues(t, 1, count, "已有有效授权时不应再发放 trial")
|
||
}
|
||
|
||
func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "HOTEL002")
|
||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||
|
||
svc := NewAuthService(db)
|
||
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword", DeviceInfo{Platform: "windows"})
|
||
|
||
assert.Error(t, err)
|
||
assert.Equal(t, ErrInvalidCredentials, err)
|
||
assert.Nil(t, pair)
|
||
assert.Nil(t, user)
|
||
}
|
||
|
||
func TestAuthService_Login_WrongHotel(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
testutil.CreateTestShop(db, "HOTEL003")
|
||
|
||
svc := NewAuthService(db)
|
||
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||
|
||
assert.Error(t, err)
|
||
assert.Equal(t, ErrInvalidCredentials, err)
|
||
assert.Nil(t, pair)
|
||
assert.Nil(t, user)
|
||
}
|
||
|
||
func TestAuthService_Login_DisabledUser(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "HOTEL004")
|
||
user := testutil.CreateTestUser(db, shop.ID, "disabled", "password123", "operator")
|
||
// 禁用用户
|
||
db.Model(user).Update("is_active", false)
|
||
|
||
svc := NewAuthService(db)
|
||
pair, u, err := svc.Login("HOTEL004", "disabled", "password123", DeviceInfo{Platform: "windows"})
|
||
|
||
assert.Error(t, err)
|
||
assert.Equal(t, ErrUserInactive, err)
|
||
assert.Nil(t, pair)
|
||
assert.Nil(t, u)
|
||
}
|
||
|
||
func TestAuthService_Login_WrongUsername(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "HOTEL005")
|
||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||
|
||
svc := NewAuthService(db)
|
||
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123", DeviceInfo{Platform: "windows"})
|
||
|
||
assert.Error(t, err)
|
||
assert.Equal(t, ErrInvalidCredentials, err)
|
||
assert.Nil(t, pair)
|
||
assert.Nil(t, user)
|
||
}
|
||
|
||
func TestAuthService_RefreshTokens(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "HOTEL006")
|
||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||
|
||
svc := NewAuthService(db)
|
||
pair, _, err := svc.Login("HOTEL006", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, pair)
|
||
|
||
// 用 refresh token 换新 token
|
||
newPair, err := svc.RefreshTokens(pair.RefreshToken)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, newPair)
|
||
assert.NotEmpty(t, newPair.AccessToken)
|
||
assert.NotEmpty(t, newPair.RefreshToken)
|
||
}
|
||
|
||
func TestAuthService_RefreshTokens_Invalid(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
testutil.InitConfig()
|
||
|
||
svc := NewAuthService(db)
|
||
newPair, err := svc.RefreshTokens("invalid.token.here")
|
||
|
||
assert.Error(t, err)
|
||
assert.Nil(t, newPair)
|
||
}
|
||
|
||
// TestLogin_AccountLockoutAfterMaxFailures 同一账号连续失败达阈值后锁定(回归)。
|
||
func TestLogin_AccountLockoutAfterMaxFailures(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
shop := testutil.CreateTestShop(db, "LOCK_ACC")
|
||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||
config.C.Session.MaxFailures = 3
|
||
svc := NewAuthService(db)
|
||
defer loginLim.reset("LOCK_ACC|admin")
|
||
|
||
for i := 0; i < 3; i++ {
|
||
_, _, err := svc.Login("LOCK_ACC", "admin", "wrong", DeviceInfo{Platform: "windows", IP: "10.0.0.1"})
|
||
require.ErrorIs(t, err, ErrInvalidCredentials)
|
||
}
|
||
// 第 4 次即便密码正确也被锁定拒绝。
|
||
_, _, err := svc.Login("LOCK_ACC", "admin", "password123", DeviceInfo{Platform: "windows", IP: "10.0.0.1"})
|
||
assert.ErrorIs(t, err, ErrTooManyAttempts)
|
||
}
|
||
|
||
// TestLogin_IPLockoutAcrossAccounts 单 IP 跨多个(不存在的)账号累计失败达 IP 阈值后锁该 IP。
|
||
// 每次用不同用户名,账号维度永不触发锁定,只有 IP 维度会锁——验证 per-IP 防撞库 + 防内存灌爆。
|
||
func TestLogin_IPLockoutAcrossAccounts(t *testing.T) {
|
||
db := testutil.SetupTestDB()
|
||
testutil.CreateTestShop(db, "LOCK_IP")
|
||
config.C.Session.MaxFailures = 5
|
||
config.C.Session.IPMaxFailures = 4
|
||
const attackIP = "203.0.113.9"
|
||
svc := NewAuthService(db)
|
||
defer loginLim.reset("ip|" + attackIP)
|
||
|
||
// 4 次不同用户名(invalid_user),账号 key 各不相同永不锁;IP key 累计到 4 → 锁 IP。
|
||
for i := 0; i < 4; i++ {
|
||
uname := "ghost" + strconv.Itoa(i)
|
||
_, _, err := svc.Login("LOCK_IP", uname, "whatever", DeviceInfo{Platform: "windows", IP: attackIP})
|
||
require.ErrorIs(t, err, ErrInvalidCredentials)
|
||
}
|
||
// 同 IP 再来一发(仍是新用户名,账号维度无锁)→ 被 IP 锁拦下。
|
||
_, _, err := svc.Login("LOCK_IP", "ghostX", "whatever", DeviceInfo{Platform: "windows", IP: attackIP})
|
||
assert.ErrorIs(t, err, ErrTooManyAttempts)
|
||
|
||
// 另一 IP 不受影响。
|
||
_, _, err = svc.Login("LOCK_IP", "ghostY", "whatever", DeviceInfo{Platform: "windows", IP: "198.51.100.7"})
|
||
assert.ErrorIs(t, err, ErrInvalidCredentials)
|
||
}
|
||
|
||
func TestHashPassword(t *testing.T) {
|
||
hash, err := HashPassword("mypassword")
|
||
require.NoError(t, err)
|
||
assert.NotEmpty(t, hash)
|
||
assert.NotEqual(t, "mypassword", hash)
|
||
|
||
// 第二次哈希应不同(bcrypt 加 salt)
|
||
hash2, err := HashPassword("mypassword")
|
||
require.NoError(t, err)
|
||
assert.NotEqual(t, hash, hash2)
|
||
}
|
||
|
||
func TestInitConfig(t *testing.T) {
|
||
testutil.InitConfig()
|
||
}
|
||
|
||
func init() {
|
||
testutil.InitConfig()
|
||
}
|