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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
Reference in New Issue
Block a user