新增活跃会话管理与通行密钥、钱包登录
CI / test (push) Successful in 55s

This commit is contained in:
2026-07-30 12:23:05 +08:00
parent f1914880ac
commit 109c345e5e
49 changed files with 6468 additions and 336 deletions
+94 -15
View File
@@ -5,6 +5,8 @@ import (
"errors"
"testing"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
@@ -17,7 +19,11 @@ func newCredEnv(t *testing.T) *AuthService {
if err != nil {
t.Fatalf("new cipher: %v", err)
}
auth.SetNotifier(nil, NewSettingService(db, cipher))
settings := NewSettingService(db, cipher)
if err := settings.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil {
t.Fatalf("seed app url: %v", err)
}
auth.SetNotifier(nil, settings)
return auth
}
@@ -26,16 +32,16 @@ func TestUpdateCredentials(t *testing.T) {
ctx := context.Background()
// 当前密码错误
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"})
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"}, proofOf(t, auth.db, "admin"))
if !errors.Is(err, ErrCredentialConfirm) {
t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err)
}
// 短密码 / 无变更均拒绝
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"})
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"}, proofOf(t, auth.db, "admin"))
if !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err)
}
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"})
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"}, proofOf(t, auth.db, "admin"))
if !errors.Is(err, ErrCredentialInvalid) {
t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err)
}
@@ -43,24 +49,60 @@ func TestUpdateCredentials(t *testing.T) {
// 同时改名改密
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123",
})
}, proofOf(t, auth.db, "admin"))
if err != nil {
t.Fatalf("UpdateCredentials: %v", err)
}
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) {
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("old username login: err = %v, want ErrInvalidCredentials", err)
}
if _, _, err := auth.Login(ctx, "root", "newpass-123", "127.0.0.2", ""); err != nil {
if _, _, err := auth.Login(ctx, "root", "newpass-123", "", SessionMeta{ClientIP: "127.0.0.2"}); err != nil {
t.Errorf("new credentials login: %v", err)
}
}
func TestUpdateCredentialsRejectsRevokedJTI(t *testing.T) {
auth := newCredEnv(t)
ctx := context.Background()
stale := loginSession(t, auth, "10.10.0.1", "stale")
current := loginSession(t, auth, "10.10.0.2", "current")
proof := mustProof(t, auth, stale)
for _, item := range mustSessions(t, auth, current) {
if !item.Current {
if err := auth.RevokeSession(ctx, "admin", current, item.ID); err != nil {
t.Fatalf("RevokeSession: %v", err)
}
}
}
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
NewPassword: "newpass-123", CurrentPassword: "pass123",
}, proof)
if !errors.Is(err, ErrTokenStale) {
t.Fatalf("UpdateCredentials err = %v, want ErrTokenStale", err)
}
if _, _, err := auth.Login(
ctx, "admin", "pass123", "", SessionMeta{ClientIP: "10.10.0.3"},
); err != nil {
t.Fatalf("old password should remain valid: %v", err)
}
}
// proofOf 取账号当前令牌版本组装快照(jti 空=不校验会话行),敏感调用测试用。
func proofOf(t *testing.T, db *gorm.DB, username string) TokenProof {
t.Helper()
var user model.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
t.Fatalf("find user: %v", err)
}
return TokenProof{Ver: user.TokenVersion}
}
func TestPasswordLoginToggle(t *testing.T) {
auth := newCredEnv(t)
ctx := context.Background()
// 未绑定外部身份不可禁用
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); !errors.Is(err, ErrNeedIdentity) {
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrNeedIdentity) {
t.Fatalf("disable without identity: err = %v, want ErrNeedIdentity", err)
}
@@ -68,36 +110,73 @@ func TestPasswordLoginToggle(t *testing.T) {
if err := auth.db.Where("username = ?", "admin").First(&user).Error; err != nil {
t.Fatalf("find admin: %v", err)
}
ident := model.UserIdentity{UserID: user.ID, Provider: "github", Subject: "1", Display: "tester"}
// 用钱包身份:不依赖 provider 配置即可登录(github/oidc 身份须 provider 启用才计入)
ident := model.UserIdentity{UserID: user.ID, Provider: "wallet", Subject: "0xabc", Display: "tester"}
if err := auth.db.Create(&ident).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); err != nil {
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); err != nil {
t.Fatalf("disable with identity: %v", err)
}
if off, err := auth.PasswordLoginDisabled(ctx); err != nil || !off {
t.Fatalf("PasswordLoginDisabled = %v, %v; want true", off, err)
}
// 密码登录被拒且不计失败(正确密码亦拒)
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrPasswordLoginDisabled) {
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrPasswordLoginDisabled) {
t.Fatalf("login while disabled: err = %v, want ErrPasswordLoginDisabled", err)
}
// 开关开着不能解绑最后一个身份
oauth := NewOAuthService(auth.db, nil, auth)
if err := oauth.Unbind(ctx, "admin", ident.ID); !errors.Is(err, ErrLastIdentity) {
if err := oauth.Unbind(ctx, "admin", ident.ID, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrLastIdentity) {
t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err)
}
// 恢复密码登录后可解绑、可登录
if err := auth.SetPasswordLoginDisabled(ctx, "admin", false); err != nil {
if err := auth.SetPasswordLoginDisabled(ctx, "admin", false, proofOf(t, auth.db, "admin")); err != nil {
t.Fatalf("enable password login: %v", err)
}
if err := oauth.Unbind(ctx, "admin", ident.ID); err != nil {
if err := oauth.Unbind(ctx, "admin", ident.ID, proofOf(t, auth.db, "admin")); err != nil {
t.Fatalf("unbind after enable: %v", err)
}
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.3", ""); err != nil {
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.3"}); err != nil {
t.Errorf("login after enable: %v", err)
}
}
// TestPasswordLoginToggleWithPasskey 验证通行密钥计入禁用密码登录的门槛:
// 仅有通行密钥即可开启;禁用期间最后一把钥匙受防自锁保护,身份可兜底解绑。
func TestPasswordLoginToggleWithPasskey(t *testing.T) {
auth := newCredEnv(t)
ctx := context.Background()
pk := model.UserPasskey{
UserID: 1, Name: "k", CredentialID: "cred-1",
CredentialIDHash: "hash-1", Credential: "{}", Origin: "https://app.example.com",
}
if err := auth.db.Create(&pk).Error; err != nil {
t.Fatalf("seed passkey: %v", err)
}
// 无外部身份、仅通行密钥:可禁用密码登录
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); err != nil {
t.Fatalf("disable with passkey only: %v", err)
}
// 禁用期间删除最后一把钥匙被拒(防自锁)
passkeys := NewPasskeyService(auth.db, auth.settings, auth)
if err := passkeys.Remove(ctx, "admin", pk.ID, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrLastIdentity) {
t.Fatalf("remove last passkey: err = %v, want ErrLastIdentity", err)
}
// 绑定身份后钥匙可删;身份成为最后方式后解绑又被拒
ident := model.UserIdentity{UserID: 1, Provider: "wallet", Subject: "0xdef", Display: "t"}
if err := auth.db.Create(&ident).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
if err := passkeys.Remove(ctx, "admin", pk.ID, proofOf(t, auth.db, "admin")); err != nil {
t.Fatalf("remove passkey with identity fallback: %v", err)
}
oauth := NewOAuthService(auth.db, nil, auth)
if err := oauth.Unbind(ctx, "admin", ident.ID, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrLastIdentity) {
t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err)
}
}