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