飞书通讯录 ↔ 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/)不入库。
256 lines
6.6 KiB
Go
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
|
|
}
|