package server import ( "encoding/base64" "net/http" "net/http/httptest" "strings" "testing" "time" ) func newTestAuth(t *testing.T) *Auth { t.Helper() a, err := NewAuth("admin", nil, "correct-password", []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false) if err != nil { t.Fatalf("构造认证器失败: %v", err) } return a } func login(t *testing.T, a *Auth, user, pass string) *http.Response { t.Helper() body := strings.NewReader(`{"username":"` + user + `","password":"` + pass + `"}`) req := httptest.NewRequest(http.MethodPost, "/api/login", body) rec := httptest.NewRecorder() err := a.Login(rec, req) if err != nil { return &http.Response{StatusCode: http.StatusUnauthorized} } return rec.Result() } func TestLoginAcceptsCorrectCredentials(t *testing.T) { a := newTestAuth(t) res := login(t, a, "admin", "correct-password") if res.StatusCode != http.StatusOK { t.Fatalf("登录失败,状态码 %d", res.StatusCode) } cookies := res.Cookies() if len(cookies) == 0 { t.Fatalf("登录成功但未下发 cookie") } var session *http.Cookie for _, c := range cookies { if c.Name == cookieName { session = c } } if session == nil { t.Fatalf("未找到会话 cookie") } // 关键安全属性 if !session.HttpOnly { t.Errorf("会话 cookie 必须设置 HttpOnly,否则可被 JS 读取") } if session.SameSite != http.SameSiteLaxMode { t.Errorf("会话 cookie 应设置 SameSite=Lax,实际 %v", session.SameSite) } } func TestLoginRejectsWrongCredentials(t *testing.T) { a := newTestAuth(t) cases := []struct { name string user string pass string }{ {"密码错误", "admin", "wrong-password"}, {"用户名错误", "root", "correct-password"}, {"均为空", "", ""}, {"密码为空", "admin", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { res := login(t, a, c.user, c.pass) if res.StatusCode == http.StatusOK { t.Errorf("错误凭据不应登录成功") } for _, ck := range res.Cookies() { if ck.Name == cookieName && ck.Value != "" { t.Errorf("登录失败时不应下发有效会话 cookie") } } }) } } func TestSessionValidation(t *testing.T) { a := newTestAuth(t) res := login(t, a, "admin", "correct-password") var token string for _, c := range res.Cookies() { if c.Name == cookieName { token = c.Value } } cases := []struct { name string value string want bool }{ {"有效会话", token, true}, {"空值", "", false}, {"无签名部分", "abc", false}, {"签名被篡改", token[:len(token)-4] + "AAAA", false}, {"伪造载荷", "ZmFrZQ.AAAA", false}, {"随机字符串", "not-a-token-at-all", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/status", nil) if c.value != "" { req.AddCookie(&http.Cookie{Name: cookieName, Value: c.value}) } if got := a.Authenticated(req); got != c.want { t.Errorf("Authenticated() = %v, 期望 %v", got, c.want) } }) } } // TestSessionRejectsForgedSignature 验证签名密钥不同则无法通过校验。 // // 这是会话机制的核心安全性:没有正确的密钥,就无法构造出有效的会话令牌。 func TestSessionRejectsForgedSignature(t *testing.T) { a := newTestAuth(t) res := login(t, a, "admin", "correct-password") var token string for _, c := range res.Cookies() { if c.Name == cookieName { token = c.Value } } // 用不同密钥的认证器校验同一个令牌 other, err := NewAuth("admin", nil, "correct-password", []byte("ffffffffffffffffffffffffffffffffffffffffffffffff"), false) if err != nil { t.Fatalf("构造失败: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/status", nil) req.AddCookie(&http.Cookie{Name: cookieName, Value: token}) if other.Authenticated(req) { t.Errorf("换了会话密钥后旧令牌不应通过校验") } } // TestSessionExpiry 验证过期会话被拒。 // // 这里直接构造一个已过期的载荷并签名,模拟「昨天登录、今天再用」的情形。 func TestSessionExpiry(t *testing.T) { a := newTestAuth(t) expired := a.sign(time.Now().Add(-time.Minute)) req := httptest.NewRequest(http.MethodGet, "/api/status", nil) req.AddCookie(&http.Cookie{Name: cookieName, Value: expired}) if a.Authenticated(req) { t.Errorf("已过期的会话不应通过校验") } } func TestMiddlewareBlocksAnonymous(t *testing.T) { a := newTestAuth(t) called := false h := a.Middleware(func(w http.ResponseWriter, r *http.Request) { called = true }) rec := httptest.NewRecorder() h(rec, httptest.NewRequest(http.MethodGet, "/api/status", nil)) if rec.Code != http.StatusUnauthorized { t.Errorf("未登录访问受保护接口应返回 401,实际 %d", rec.Code) } if called { t.Errorf("未登录时不应执行受保护的处理逻辑") } } func TestLogoutClearsCookie(t *testing.T) { a := newTestAuth(t) rec := httptest.NewRecorder() a.Logout(rec) var cleared bool for _, c := range rec.Result().Cookies() { if c.Name == cookieName && c.MaxAge < 0 { cleared = true } } if !cleared { t.Errorf("登出应清除会话 cookie") } } func TestNewAuthRequiresSecret(t *testing.T) { if _, err := NewAuth("admin", nil, "pw", []byte("short"), false); err == nil { t.Errorf("过短的会话密钥应被拒绝") } if _, err := NewAuth("admin", nil, "", []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false); err == nil { t.Errorf("缺少密码时应被拒绝") } } func TestHashPasswordRoundTrip(t *testing.T) { h, err := HashPassword("my-secret") if err != nil { t.Fatalf("生成哈希失败: %v", err) } // 生成的哈希应能通过 NewAuth 校验 a, err := NewAuth("admin", mustDecodeB64(t, h), "", []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false) if err != nil { t.Fatalf("用哈希构造认证器失败: %v", err) } if res := login(t, a, "admin", "my-secret"); res.StatusCode != http.StatusOK { t.Errorf("用生成的哈希登录失败") } if res := login(t, a, "admin", "wrong"); res.StatusCode == http.StatusOK { t.Errorf("错误密码不应通过") } } func TestGenerateSecret(t *testing.T) { a, err := GenerateSecret() if err != nil { t.Fatalf("生成失败: %v", err) } b, err := GenerateSecret() if err != nil { t.Fatalf("生成失败: %v", err) } if a == b { t.Errorf("两次生成的密钥相同") } if len(a) < 32 { t.Errorf("密钥过短: %d", len(a)) } } func mustDecodeB64(t *testing.T, s string) []byte { t.Helper() dec, err := base64.StdEncoding.DecodeString(s) if err != nil { t.Fatalf("base64 解码失败: %v", err) } return dec }