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:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
// Package server 提供 WebUI 所需的 HTTP 接口与静态资源。
//
// 认证只做一件事:保护这个管理界面本身。用户认证完全由 Authelia 承担,
// 本工具不参与任何登录链路,因此这里就是一个单管理员的会话,不做角色体系。
package server
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// SessionTTL 是会话有效期。
const SessionTTL = 12 * time.Hour
// cookieName 是会话 cookie 的名称。
const cookieName = "idsync_session"
// Auth 负责单管理员认证与会话签名。
type Auth struct {
username string
passwordHash []byte
secret []byte
secureCookie bool
}
// NewAuth 构造认证器。passwordHash 为空时用明文密码生成 bcrypt 哈希。
func NewAuth(username string, passwordHash []byte, plainPassword string, secret []byte, secureCookie bool) (*Auth, error) {
if len(passwordHash) == 0 {
if plainPassword == "" {
return nil, fmt.Errorf("未配置管理员密码")
}
h, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("生成密码哈希失败: %w", err)
}
passwordHash = h
}
if len(secret) < 32 {
return nil, fmt.Errorf("会话密钥长度不足(至少 32 字节)")
}
return &Auth{
username: username,
passwordHash: passwordHash,
secret: secret,
secureCookie: secureCookie,
}, nil
}
// PasswordHashB64 返回可写回配置文件的哈希(base64)。
func (a *Auth) PasswordHashB64() string {
return base64.StdEncoding.EncodeToString(a.passwordHash)
}
// Login 校验凭据;成功则下发签名 cookie。
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) error {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return fmt.Errorf("请求格式错误")
}
// 用户名与密码都要做恒定时间比较,避免通过响应时间探测用户名是否存在。
userOK := subtle.ConstantTimeCompare([]byte(req.Username), []byte(a.username)) == 1
passErr := bcrypt.CompareHashAndPassword(a.passwordHash, []byte(req.Password))
if !userOK || passErr != nil {
return fmt.Errorf("用户名或密码错误")
}
token := a.sign(time.Now().Add(SessionTTL))
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: a.secureCookie,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(SessionTTL),
})
return nil
}
// Logout 清除会话 cookie。
func (a *Auth) Logout(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: a.secureCookie,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// Authenticated 判断请求是否带有有效会话。
func (a *Auth) Authenticated(r *http.Request) bool {
c, err := r.Cookie(cookieName)
if err != nil || c.Value == "" {
return false
}
payload, sig, ok := strings.Cut(c.Value, ".")
if !ok {
return false
}
if !hmac.Equal([]byte(sig), []byte(a.signPayload(payload))) {
return false
}
// 载荷即过期时间的 Unix 秒
exp, err := time.Parse(time.RFC3339, decodeB64(payload))
if err != nil {
return false
}
return time.Now().Before(exp)
}
// Middleware 保护需要登录的接口。
func (a *Auth) Middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.Authenticated(r) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "未登录"})
return
}
next(w, r)
}
}
// sign 生成 "payload.signature" 形式的令牌。
func (a *Auth) sign(exp time.Time) string {
payload := encodeB64(exp.UTC().Format(time.RFC3339))
return payload + "." + a.signPayload(payload)
}
// signPayload 用 HMAC-SHA256 对载荷签名。
func (a *Auth) signPayload(payload string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(payload))
return encodeB64Bytes(mac.Sum(nil))
}
// GenerateSecret 生成会话密钥。
func GenerateSecret() (string, error) {
b := make([]byte, 48)
if _, err := rand.Read(b); err != nil {
return "", err
}
return encodeB64Bytes(b), nil
}
func encodeB64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
func encodeB64Bytes(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func decodeB64(s string) string {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return ""
}
return string(b)
}
// WithTimeout 给 handler 提供一个带超时的 context。
func WithTimeout(d time.Duration, fn func(ctx context.Context, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
fn(ctx, w, r)
}
}
// HashPassword 为给定明文密码生成 bcrypt 哈希(base64 编码),供 -hash-password 使用。
func HashPassword(plain string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(h), nil
}
+255
View File
@@ -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
}
+326
View File
@@ -0,0 +1,326 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"idsync/internal/config"
"idsync/internal/ldaplib"
"idsync/internal/store"
"idsync/internal/syncer"
"idsync/internal/webui"
)
// Deps 是服务器依赖。
type Deps struct {
Cfg *config.Config
Auth *Auth
Store *store.Store
Runner *syncer.Runner
LDAP *ldaplib.Client
// FeishuTest 检查飞书连通性
FeishuTest func(ctx context.Context) error
Log *slog.Logger
}
// Server 装配路由。
type Server struct {
deps *Deps
mux *http.ServeMux
// syncMu 保证同一时刻只跑一次同步,避免两次点击互相踩踏。
syncMu chan struct{}
}
// New 构造服务器。
func New(d *Deps) *Server {
s := &Server{
deps: d,
mux: http.NewServeMux(),
syncMu: make(chan struct{}, 1),
}
s.routes()
return s
}
// Handler 返回可挂载到 http.Server 的处理器。
func (s *Server) Handler() http.Handler { return s.mux }
func (s *Server) routes() {
// 静态资源与登录页不需要认证。
s.mux.HandleFunc("/", s.handleIndex)
s.mux.HandleFunc("/static/", webui.AssetHandler())
s.mux.HandleFunc("/api/login", s.handleLogin)
s.mux.HandleFunc("/api/logout", s.handleLogout)
// 以下接口需要登录。
s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus))
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers))
s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups))
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan))
s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync))
s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns))
}
// handleIndex 返回内嵌的前端页面;未知的 /api/ 路径返回 404 而非首页。
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "接口不存在"})
return
}
webui.IndexHandler()(w, r)
}
// handleLogin 处理登录。
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return
}
if err := s.deps.Auth.Login(w, r); err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleLogout 处理登出。
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.deps.Auth.Logout(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleStatus 返回概览信息:连通性、计数、上次同步。
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
cfg := s.deps.Cfg
ldapOK := true
ldapErr := ""
if err := s.deps.LDAP.Ping(); err != nil {
ldapOK = false
ldapErr = err.Error()
}
feishuOK := true
feishuErr := ""
if s.deps.FeishuTest != nil {
if err := s.deps.FeishuTest(ctx); err != nil {
feishuOK = false
feishuErr = err.Error()
}
}
accounts, err := s.deps.Store.AllAccounts()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
groups, err := s.deps.Store.AllGroups()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
active, inactive := 0, 0
for _, a := range accounts {
if a.State == "inactive" {
inactive++
} else {
active++
}
}
lastRun, err := s.deps.Store.LastRun()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ldap": map[string]any{
"ok": ldapOK, "error": ldapErr,
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN,
"userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(),
},
"feishu": map[string]any{
"ok": feishuOK, "error": feishuErr,
"appId": maskSecret(cfg.Feishu.AppID),
"deptList": cfg.Feishu.DeptList,
},
"counts": map[string]any{
"activeUsers": active, "inactiveUsers": inactive, "groups": len(groups),
},
"lastRun": lastRun,
"sync": map[string]any{
"enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule,
"defaultDryRun": cfg.Sync.DefaultDryRun,
},
})
}
// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
accounts, err := s.deps.Store.AllAccounts()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项,
// 这类问题在界面上一眼可见比事后翻日志有用得多。
drift := map[string]string{}
if conn, err := s.deps.LDAP.Dial(); err == nil {
defer conn.Close()
active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN())
inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN())
if errA == nil && errI == nil {
for _, a := range accounts {
switch a.State {
case "inactive":
if _, ok := inactive[a.UID]; !ok {
drift[a.UID] = "台账标记离职,但 LDAP 中不存在"
}
default:
if _, ok := active[a.UID]; !ok {
if _, inInactive := inactive[a.UID]; inInactive {
drift[a.UID] = "台账标记在职,实际位于离职 OU"
} else {
drift[a.UID] = "台账存在,但 LDAP 中不存在"
}
}
}
}
}
}
type row struct {
UID string `json:"uid"`
Name string `json:"name"`
Email string `json:"email"`
State string `json:"state"`
DN string `json:"dn"`
DeptIDs string `json:"deptIds"`
LastSync string `json:"lastSyncedAt"`
Drift string `json:"drift,omitempty"`
}
rows := make([]row, 0, len(accounts))
for _, a := range accounts {
rows = append(rows, row{
UID: a.UID,
Name: a.Name,
Email: a.Email,
State: a.State,
DN: a.DN,
DeptIDs: a.DeptIDs,
LastSync: a.LastSyncedAt.Format(time.RFC3339),
Drift: drift[a.UID],
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": rows})
}
// handleGroups 返回受管分组清单。
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.deps.Store.AllGroups()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
// handlePlan 计算并返回 dry-run 计划,不产生任何写入。
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
plan, err := s.deps.Runner.Preview(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, plan)
}
// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return
}
var req struct {
DryRun *bool `json:"dryRun"`
Force bool `json:"force"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
// 默认 dry-run:忘记传参数时宁可什么都不做。
dryRun := true
if req.DryRun != nil {
dryRun = *req.DryRun
}
// 同一时刻只允许一次同步。
select {
case s.syncMu <- struct{}{}:
defer func() { <-s.syncMu }()
default:
writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(), "plan": plan,
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"dryRun": dryRun, "plan": plan, "result": res,
})
}
// handleRuns 返回同步历史。
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.deps.Store.RecentRuns(100)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
}
// writeJSON 输出 JSON 响应。
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。
func maskSecret(s string) string {
if len(s) <= 8 {
if len(s) == 0 {
return "未配置"
}
return "****"
}
return s[:4] + "****" + s[len(s)-4:]
}