Files
idsync/internal/server/auth.go
T
caowangrenbo 947b1a1475 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/)不入库。
2026-09-29 00:10:59 +08:00

189 lines
5.2 KiB
Go

// 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
}