Files
idsync/internal/server/auth_test.go
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

256 lines
6.6 KiB
Go

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
}