@@ -0,0 +1,381 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"oci-portal/internal/crypto"
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// newTestWallet 组装内存库上的 WalletService(admin 已建,面板地址经环境回退注入)。
|
||||
func newTestWallet(t *testing.T, appURL string) *WalletService {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserIdentity{}, &model.UserSession{}, &model.Setting{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
auth := NewAuthService(db, "test-jwt-secret")
|
||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||
t.Fatalf("ensure admin: %v", err)
|
||||
}
|
||||
cipher, err := crypto.NewCipher("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
settings := NewSettingService(db, cipher)
|
||||
settings.SetEnvPublicURL(appURL)
|
||||
return NewWalletService(db, settings, auth)
|
||||
}
|
||||
|
||||
func TestWalletChallenge(t *testing.T) {
|
||||
priv := testPrivKey(9)
|
||||
addr := ethAddrOf(priv)
|
||||
tests := []struct {
|
||||
name string
|
||||
appURL string
|
||||
address string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "正常下发", appURL: "https://demo.example.com", address: strings.ToLower(addr)},
|
||||
{name: "无面板地址", appURL: "", address: addr, wantErr: ErrWalletNoAppURL},
|
||||
{name: "地址非法", appURL: "https://demo.example.com", address: "0x12", wantErr: ErrWalletAddress},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := newTestWallet(t, tt.appURL)
|
||||
nonce, message, err := w.Challenge(context.Background(), tt.address, "login", "", "")
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("Challenge err = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge: %v", err)
|
||||
}
|
||||
// 消息含 EIP-55 规范地址、nonce 与 domain;地址大小写已被规范化
|
||||
for _, part := range []string{addr, "Nonce: " + nonce, "demo.example.com wants you"} {
|
||||
if !strings.Contains(message, part) {
|
||||
t.Errorf("message missing %q:\n%s", part, message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// adminToken 以口令登录换取 admin 的有效会话令牌(bind 挑战的复验凭据)。
|
||||
func adminToken(t *testing.T, w *WalletService) string {
|
||||
t.Helper()
|
||||
token, _, err := w.auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "10.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("admin login: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// bindWallet 走完整 bind 流程(挑战 → 签名 → 校验),返回校验结果。
|
||||
func bindWallet(t *testing.T, w *WalletService, seed byte) WalletVerifyResult {
|
||||
t.Helper()
|
||||
priv := testPrivKey(seed)
|
||||
nonce, message, err := w.Challenge(context.Background(), ethAddrOf(priv), "bind", "admin", adminToken(t, w))
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(bind): %v", err)
|
||||
}
|
||||
res, err := w.Verify(context.Background(), nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(bind): %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestWalletBindAndLogin(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
addr := ethAddrOf(priv)
|
||||
|
||||
res := bindWallet(t, w, 9)
|
||||
if res.Mode != "bind" || res.Token == "" || res.Username != "admin" {
|
||||
t.Fatalf("bind result = %+v, want bind/admin with token", res)
|
||||
}
|
||||
var row model.UserIdentity
|
||||
if err := w.db.Where("provider = ? AND subject = ?", walletProvider, addr).First(&row).Error; err != nil {
|
||||
t.Fatalf("identity row: %v", err)
|
||||
}
|
||||
if row.Display != shortEthAddress(addr) {
|
||||
t.Errorf("display = %q, want %q", row.Display, shortEthAddress(addr))
|
||||
}
|
||||
// 钱包身份计入外部身份数(禁用密码登录的门槛)
|
||||
if n, err := identityCountTx(w.db, row.UserID); err != nil || n != 1 {
|
||||
t.Errorf("identityCountTx = (%d, %v), want 1", n, err)
|
||||
}
|
||||
if !w.HasAny(ctx) {
|
||||
t.Error("HasAny = false, want true after bind")
|
||||
}
|
||||
|
||||
// 重复绑定同一地址被拒
|
||||
nonce, message, err := w.Challenge(ctx, addr, "bind", "admin", adminToken(t, w))
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(rebind): %v", err)
|
||||
}
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1"}); !errors.Is(err, ErrWalletBound) {
|
||||
t.Fatalf("rebind err = %v, want ErrWalletBound", err)
|
||||
}
|
||||
|
||||
// 登录:全大写地址输入被规范化,v=0/1 形态签名同样有效
|
||||
nonce, message, err = w.Challenge(ctx, "0x"+strings.ToUpper(addr[2:]), "login", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(login): %v", err)
|
||||
}
|
||||
got, err := w.Verify(ctx, nonce, signPersonal(priv, message, false), SessionMeta{ClientIP: "10.1.0.2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(login): %v", err)
|
||||
}
|
||||
if got.Mode != "login" || got.Username != "admin" || got.Token == "" || got.ExpiresAt.IsZero() {
|
||||
t.Fatalf("login result = %+v, want login/admin with token+expiry", got)
|
||||
}
|
||||
if _, err := w.auth.ParseToken(ctx, got.Token); err != nil {
|
||||
t.Errorf("issued token invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletVerifyRejections(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
bound := testPrivKey(9)
|
||||
bindWallet(t, w, 9)
|
||||
|
||||
t.Run("nonce 一次性", func(t *testing.T) {
|
||||
nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "")
|
||||
sig := signPersonal(bound, message, true)
|
||||
if _, err := w.Verify(ctx, nonce, sig, SessionMeta{ClientIP: "10.2.0.1"}); err != nil {
|
||||
t.Fatalf("first verify: %v", err)
|
||||
}
|
||||
if _, err := w.Verify(ctx, nonce, sig, SessionMeta{ClientIP: "10.2.0.1"}); !errors.Is(err, ErrWalletChallenge) {
|
||||
t.Errorf("replayed nonce err = %v, want ErrWalletChallenge", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("过期挑战", func(t *testing.T) {
|
||||
nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "")
|
||||
w.mu.Lock()
|
||||
p := w.pending[nonce]
|
||||
p.expires = time.Now().Add(-time.Second)
|
||||
w.pending[nonce] = p
|
||||
w.mu.Unlock()
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(bound, message, true), SessionMeta{ClientIP: "10.2.0.2"}); !errors.Is(err, ErrWalletChallenge) {
|
||||
t.Errorf("expired nonce err = %v, want ErrWalletChallenge", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("他人签名(地址不匹配)", func(t *testing.T) {
|
||||
nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "")
|
||||
other := testPrivKey(13)
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.2.0.3"}); !errors.Is(err, ErrWalletSig) {
|
||||
t.Errorf("foreign signature err = %v, want ErrWalletSig", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未绑定地址拒登", func(t *testing.T) {
|
||||
stranger := testPrivKey(21)
|
||||
nonce, message, _ := w.Challenge(ctx, ethAddrOf(stranger), "login", "", "")
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(stranger, message, true), SessionMeta{ClientIP: "10.2.0.4"}); !errors.Is(err, ErrWalletNotBound) {
|
||||
t.Errorf("unbound address err = %v, want ErrWalletNotBound", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalletBindStaleToken(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
|
||||
// 挑战发起后全量撤销(TokenVersion 自增),verify 复验须拒绝绑定
|
||||
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", adminToken(t, w))
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(bind): %v", err)
|
||||
}
|
||||
if err := w.auth.bumpTokenVersion(ctx, "admin"); err != nil {
|
||||
t.Fatalf("bump token version: %v", err)
|
||||
}
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.4.0.1"}); !errors.Is(err, ErrWalletChallenge) {
|
||||
t.Fatalf("stale-token bind err = %v, want ErrWalletChallenge", err)
|
||||
}
|
||||
if w.HasAny(ctx) {
|
||||
t.Error("HasAny = true, want false: stale-token bind must not persist identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletBindStaleVersion 验证事务内版本比对:令牌复验通过但发起后版本
|
||||
// 已变(极窄竞态窗口)的绑定同样被拒,身份不落库。
|
||||
func TestWalletBindStaleVersion(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", adminToken(t, w))
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(bind): %v", err)
|
||||
}
|
||||
w.mu.Lock()
|
||||
p := w.pending[nonce]
|
||||
p.proof.Ver--
|
||||
w.pending[nonce] = p
|
||||
w.mu.Unlock()
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.4.0.2"}); !errors.Is(err, ErrWalletChallenge) {
|
||||
t.Fatalf("stale-version bind err = %v, want ErrWalletChallenge", err)
|
||||
}
|
||||
if w.HasAny(ctx) {
|
||||
t.Error("HasAny = true, want false: stale-version bind must not persist identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletBindKeepsSession 验证绑定接续当前会话:原行同行更新,
|
||||
// 保留登录方式、行 ID 与 jti,且新令牌有效。
|
||||
func TestWalletBindKeepsSession(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
token := adminToken(t, w)
|
||||
before := sessionRows(t, w.auth)
|
||||
if len(before) != 1 || before[0].Method != "password" {
|
||||
t.Fatalf("seed session = %+v, want single password row", before)
|
||||
}
|
||||
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token)
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(bind): %v", err)
|
||||
}
|
||||
res, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1", UserAgent: "UA"})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(bind): %v", err)
|
||||
}
|
||||
after := sessionRows(t, w.auth)
|
||||
if len(after) != 1 || after[0].ID != before[0].ID {
|
||||
t.Fatalf("sessions after bind = %+v, want same single row (continuity)", after)
|
||||
}
|
||||
if after[0].Method != "password" || after[0].TokenID != before[0].TokenID {
|
||||
t.Errorf("renewed row = %+v, want method and jti kept", after[0])
|
||||
}
|
||||
if _, err := w.auth.ParseToken(ctx, res.Token); err != nil {
|
||||
t.Errorf("issued token invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletBindRevokedSession 验证绑定事务的 jti 复核:挑战发起后该设备
|
||||
// 会话被定点撤销(版本未变),验签通过的绑定仍须拒绝且身份不落库。
|
||||
func TestWalletBindRevokedSession(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
token := adminToken(t, w)
|
||||
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token)
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(bind): %v", err)
|
||||
}
|
||||
// 模拟入口复验之后、事务之前的撤销:直接标记该 jti 的会话行 revoked
|
||||
// (入口 ParseToken 走缓存路径时可能尚未察觉,事务内查库是最终防线)
|
||||
w.auth.revokeSessionByJTI(ctx, "admin", w.auth.signedJti(token), tokenTTL)
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.5.0.1"}); err == nil {
|
||||
t.Fatal("bind after session revoke succeeded, want rejection")
|
||||
}
|
||||
if w.HasAny(ctx) {
|
||||
t.Error("HasAny = true, want false: revoked-session bind must not persist identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletBindNoRowMethodEmpty 验证旧版无会话行令牌绑定时,
|
||||
// 新建会话行的登录方式为空(该令牌并非经钱包登录,与活跃会话设计一致)。
|
||||
func TestWalletBindNoRowMethodEmpty(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
token := adminToken(t, w)
|
||||
// 删除会话行,模拟升级前签发的存量令牌(无行)
|
||||
if err := w.db.Where("1 = 1").Delete(&model.UserSession{}).Error; err != nil {
|
||||
t.Fatalf("clear sessions: %v", err)
|
||||
}
|
||||
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token)
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge(bind): %v", err)
|
||||
}
|
||||
res, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.5.0.2", UserAgent: "UA"})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(bind): %v", err)
|
||||
}
|
||||
rows := sessionRows(t, w.auth)
|
||||
if len(rows) != 1 || rows[0].Method != "" {
|
||||
t.Fatalf("rows = %+v, want single row with empty method", rows)
|
||||
}
|
||||
if _, err := w.auth.ParseToken(ctx, res.Token); err != nil {
|
||||
t.Errorf("issued token invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletLoginRechecksIdentity(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
bindWallet(t, w, 9)
|
||||
var row model.UserIdentity
|
||||
if err := w.db.Where("provider = ?", walletProvider).First(&row).Error; err != nil {
|
||||
t.Fatalf("find identity: %v", err)
|
||||
}
|
||||
if err := w.db.Delete(&row).Error; err != nil {
|
||||
t.Fatalf("delete identity: %v", err)
|
||||
}
|
||||
_, _, _, err := w.loginWalletIdentity(
|
||||
context.Background(), &row, row.Subject, SessionMeta{ClientIP: "10.6.0.1"})
|
||||
if !errors.Is(err, ErrWalletNotBound) {
|
||||
t.Fatalf("login err = %v, want ErrWalletNotBound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletHasAnyRequiresAppURL(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
row := model.UserIdentity{UserID: 1, Provider: walletProvider, Subject: "0xabc"}
|
||||
if err := w.db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed identity: %v", err)
|
||||
}
|
||||
if !w.HasAny(context.Background()) {
|
||||
t.Fatal("HasAny = false, want true with app url")
|
||||
}
|
||||
w.settings.SetEnvPublicURL("")
|
||||
if w.HasAny(context.Background()) {
|
||||
t.Fatal("HasAny = true without app url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletLoginGuardLock(t *testing.T) {
|
||||
w := newTestWallet(t, "https://demo.example.com")
|
||||
ctx := context.Background()
|
||||
priv := testPrivKey(9)
|
||||
addr := ethAddrOf(priv)
|
||||
other := testPrivKey(13)
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < securityDefaults.LoginFailLimit+1; i++ {
|
||||
nonce, message, err := w.Challenge(ctx, addr, "login", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Challenge #%d: %v", i, err)
|
||||
}
|
||||
_, lastErr = w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.3.0.1"})
|
||||
}
|
||||
if !errors.Is(lastErr, ErrLoginLocked) {
|
||||
t.Fatalf("after %d bad signatures err = %v, want ErrLoginLocked", securityDefaults.LoginFailLimit+1, lastErr)
|
||||
}
|
||||
// 锁定针对「IP+地址」:另一 IP 不连坐
|
||||
nonce, message, _ := w.Challenge(ctx, addr, "login", "", "")
|
||||
if _, err := w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.3.0.2"}); errors.Is(err, ErrLoginLocked) {
|
||||
t.Errorf("different IP got locked prematurely: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user