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/)不入库。
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user