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