package admin import ( "context" "crypto/rand" "testing" "time" "github.com/wangjia/pangolin/server/internal/totp" ) // newTrustAuth builds an Authenticator with device-trust ENABLED (30d). func newTrustAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) { t.Helper() rdb, _ := newTestRedis(t) key := make([]byte, 32) if _, err := rand.Read(key); err != nil { t.Fatal(err) } store := newFakeStore() cfg := &Config{ SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute, SessionTTL: 30 * time.Minute, TrustedDeviceTTL: 30 * 24 * time.Hour, } sessions := NewSessionStore(rdb, cfg.SessionTTL) sec := NewSecurityLog(store, nil) return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key } // 勾选「记住此设备」成功登录 → 返回可用于下次跳过 TOTP 的信任令牌。 func TestLoginDevice_RememberIssuesTrust(t *testing.T) { auth, store, key := newTrustAuth(t) secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") code, _ := totp.Code(secret, time.Now().UTC()) ctx := context.Background() res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true) if err != nil { t.Fatalf("login: %v", err) } if res.NewTrustToken == "" { t.Fatal("remember=true should issue a trust token") } if !res.Persistent { t.Error("remember=true should mark session persistent") } // 下次:带该令牌 + 空 TOTP 也能登录(跳过二次验证) res2, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", res.NewTrustToken, false) if err != nil { t.Fatalf("trusted re-login should skip TOTP: %v", err) } if res2.SID == "" { t.Fatal("no session on trusted re-login") } } // 不勾选:不签发令牌,且 TOTP 仍必填。 func TestLoginDevice_NoRememberRequiresTOTP(t *testing.T) { auth, store, key := newTrustAuth(t) secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") code, _ := totp.Code(secret, time.Now().UTC()) ctx := context.Background() res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", false) if err != nil { t.Fatalf("login: %v", err) } if res.NewTrustToken != "" { t.Error("remember=false must not issue a trust token") } // 无令牌 + 空 TOTP → 拒 if _, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", "", false); err != ErrInvalidCredentials { t.Errorf("untrusted device with empty TOTP should fail, got %v", err) } } // 铁律:即便持有效信任令牌,密码错误一律拒(只跳过 TOTP,不跳过密码)。 func TestLoginDevice_TrustNeverSkipsPassword(t *testing.T) { auth, store, key := newTrustAuth(t) secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") code, _ := totp.Code(secret, time.Now().UTC()) ctx := context.Background() res, _ := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true) if res.NewTrustToken == "" { t.Fatal("precondition: expected trust token") } if _, err := auth.LoginDevice(ctx, "alice", "WRONG", "", "127.0.0.1", res.NewTrustToken, false); err != ErrInvalidCredentials { t.Errorf("trusted device must still require correct password, got %v", err) } } // 信任令牌绑定 admin:换个用户名不认(令牌属 alice,拿去登 bob 无效)。 func TestLoginDevice_TrustBoundToAdmin(t *testing.T) { auth, store, key := newTrustAuth(t) sa := newTestAdmin(t, store, key, "alice", "alice-pass") _ = sa // bob:另一个 admin,不同 ID hash, _ := HashPassword("bob-pass") bsecret, _ := totp.GenerateSecret() benc, _ := EncryptSecret(key, bsecret) store.admins["bob"] = &Admin{ID: 2, Username: "bob", PwHash: hash, TOTPSecretEnc: benc, Status: "active"} ctx := context.Background() acode, _ := totp.Code(sa, time.Now().UTC()) ares, _ := auth.LoginDevice(ctx, "alice", "alice-pass", acode, "127.0.0.1", "", true) if ares.NewTrustToken == "" { t.Fatal("precondition: alice trust token") } // 用 alice 的令牌 + 空 TOTP 登 bob → 应要求 TOTP(令牌对 bob 无效) if _, err := auth.LoginDevice(ctx, "bob", "bob-pass", "", "127.0.0.1", ares.NewTrustToken, false); err != ErrInvalidCredentials { t.Errorf("alice's trust token must not skip TOTP for bob, got %v", err) } }