1
0
Fork 0
mirror of https://github.com/adnanh/webhook.git synced 2026-07-21 17:29:49 +00:00
webhook/admin.go
jason.liao 8272126474 feat: 添加管理配置功能
默认不启用,需要通过启动参数开启
2026-04-28 10:35:11 +08:00

479 lines
13 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/adnanh/webhook/internal/hook"
"github.com/gorilla/mux"
)
var (
adminEnabled = flag.Bool("admin", false, "enable the admin UI and API for managing hooks")
adminURLPrefix = flag.String("admin-path", "admin", "url prefix to use for the admin UI and API")
adminTOTPSecret = flag.String("admin-totp-secret", "", "base32 encoded TOTP secret for admin login")
adminJWTSecret = flag.String("admin-jwt-secret", "", "secret used to sign admin JWT sessions")
adminSessionTTL = flag.String("admin-session-ttl", "12h", "lifetime of an admin JWT session")
)
const adminTokenCookieName = "webhook_admin_token"
type adminAuthConfig struct {
basePath string
totpSecret []byte
jwtSecret []byte
sessionTTL time.Duration
}
type adminJWTHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
}
type adminJWTClaims struct {
Subject string `json:"sub"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
}
type adminLoginRequest struct {
Code string `json:"code"`
}
type adminHookMutationRequest struct {
File string `json:"file"`
CurrentID string `json:"currentId,omitempty"`
Hook hook.Hook `json:"hook"`
}
var currentAdminAuth *adminAuthConfig
func initAdmin() error {
if !*adminEnabled {
return nil
}
trimmedAdminPath := strings.Trim(strings.TrimSpace(*adminURLPrefix), "/")
if trimmedAdminPath == "" {
return errors.New("admin-path must not be empty")
}
trimmedHooksPath := strings.Trim(strings.TrimSpace(*hooksURLPrefix), "/")
if trimmedHooksPath == trimmedAdminPath {
return errors.New("admin-path must not match urlprefix")
}
ttl, err := time.ParseDuration(strings.TrimSpace(*adminSessionTTL))
if err != nil {
return fmt.Errorf("invalid admin-session-ttl: %w", err)
}
if ttl <= 0 {
return errors.New("admin-session-ttl must be greater than zero")
}
totpSecret, err := decodeTOTPSecret(*adminTOTPSecret)
if err != nil {
return fmt.Errorf("invalid admin-totp-secret: %w", err)
}
jwtSecret := strings.TrimSpace(*adminJWTSecret)
if jwtSecret == "" {
return errors.New("admin-jwt-secret must not be empty")
}
*adminURLPrefix = trimmedAdminPath
currentAdminAuth = &adminAuthConfig{
basePath: makeBaseURL(adminURLPrefix),
totpSecret: totpSecret,
jwtSecret: []byte(jwtSecret),
sessionTTL: ttl,
}
return nil
}
func registerAdminRoutes(r *mux.Router) {
if !*adminEnabled {
return
}
basePath := currentAdminAuth.basePath
staticHandler := http.StripPrefix(basePath+"/", adminStaticHandler())
r.HandleFunc(basePath, adminUIHandler).Methods(http.MethodGet)
r.HandleFunc(basePath+"/", adminUIHandler).Methods(http.MethodGet)
r.HandleFunc(basePath+"/api/auth/login", adminLoginHandler).Methods(http.MethodPost)
r.HandleFunc(basePath+"/api/auth/logout", adminLogoutHandler).Methods(http.MethodPost)
r.HandleFunc(basePath+"/api/config", adminRequireAuth(adminConfigHandler)).Methods(http.MethodGet)
r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminCreateHookHandler)).Methods(http.MethodPost)
r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminUpdateHookHandler)).Methods(http.MethodPut)
r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminDeleteHookHandler)).Methods(http.MethodDelete)
r.PathPrefix(basePath + "/assets/").Handler(staticHandler).Methods(http.MethodGet)
}
func decodeTOTPSecret(secret string) ([]byte, error) {
normalized := strings.ToUpper(strings.TrimSpace(secret))
replacer := strings.NewReplacer(" ", "", "-", "", "\n", "", "\r", "", "\t", "", "=", "")
normalized = replacer.Replace(normalized)
if normalized == "" {
return nil, errors.New("secret is empty")
}
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
if err == nil {
return decoded, nil
}
if rem := len(normalized) % 8; rem != 0 {
normalized += strings.Repeat("=", 8-rem)
}
return base32.StdEncoding.DecodeString(normalized)
}
func hotpCode(secret []byte, counter uint64, digits int) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, secret)
_, _ = mac.Write(buf[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
value := (int(sum[offset])&0x7f)<<24 |
int(sum[offset+1])<<16 |
int(sum[offset+2])<<8 |
int(sum[offset+3])
mod := 1
for i := 0; i < digits; i++ {
mod *= 10
}
code := value % mod
format := "%0" + strconv.Itoa(digits) + "d"
return fmt.Sprintf(format, code)
}
func verifyTOTP(secret []byte, code string, now time.Time) bool {
code = strings.TrimSpace(code)
if len(code) != 6 {
return false
}
for _, ch := range code {
if ch < '0' || ch > '9' {
return false
}
}
counter := now.Unix() / 30
for offset := int64(-1); offset <= 1; offset++ {
current := counter + offset
if current < 0 {
continue
}
expected := hotpCode(secret, uint64(current), 6)
if hmac.Equal([]byte(expected), []byte(code)) {
return true
}
}
return false
}
func signAdminJWT(now time.Time) (string, error) {
if currentAdminAuth == nil {
return "", errors.New("admin auth is not initialized")
}
header := adminJWTHeader{
Algorithm: "HS256",
Type: "JWT",
}
claims := adminJWTClaims{
Subject: "webhook-admin",
IssuedAt: now.Unix(),
NotBefore: now.Add(-30 * time.Second).Unix(),
ExpiresAt: now.Add(currentAdminAuth.sessionTTL).Unix(),
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret)
_, _ = mac.Write([]byte(payload))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payload + "." + signature, nil
}
func parseAdminJWT(token string, now time.Time) (*adminJWTClaims, error) {
if currentAdminAuth == nil {
return nil, errors.New("admin auth is not initialized")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("invalid token format")
}
signed := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errors.New("invalid token signature encoding")
}
mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret)
_, _ = mac.Write([]byte(signed))
expected := mac.Sum(nil)
if !hmac.Equal(signature, expected) {
return nil, errors.New("invalid token signature")
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errors.New("invalid token header encoding")
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, errors.New("invalid token payload encoding")
}
var header adminJWTHeader
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, errors.New("invalid token header")
}
if header.Algorithm != "HS256" || header.Type != "JWT" {
return nil, errors.New("unsupported token header")
}
var claims adminJWTClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return nil, errors.New("invalid token payload")
}
if claims.Subject != "webhook-admin" {
return nil, errors.New("invalid token subject")
}
nowUnix := now.Unix()
if claims.NotBefore != 0 && nowUnix < claims.NotBefore {
return nil, errors.New("token is not active yet")
}
if claims.ExpiresAt == 0 || nowUnix >= claims.ExpiresAt {
return nil, errors.New("token has expired")
}
return &claims, nil
}
func setAdminTokenCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: adminTokenCookieName,
Value: token,
Path: currentAdminAuth.basePath,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: *secure,
MaxAge: int(currentAdminAuth.sessionTTL.Seconds()),
})
}
func clearAdminTokenCookie(w http.ResponseWriter) {
path := "/"
if currentAdminAuth != nil {
path = currentAdminAuth.basePath
}
http.SetCookie(w, &http.Cookie{
Name: adminTokenCookieName,
Value: "",
Path: path,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: *secure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func adminTokenFromRequest(r *http.Request) string {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return strings.TrimSpace(authHeader[7:])
}
cookie, err := r.Cookie(adminTokenCookieName)
if err != nil {
return ""
}
return cookie.Value
}
func authenticateAdminRequest(r *http.Request) error {
token := adminTokenFromRequest(r)
if token == "" {
return errors.New("missing admin token")
}
_, err := parseAdminJWT(token, time.Now())
return err
}
func adminRequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := authenticateAdminRequest(r); err != nil {
writeAdminError(w, http.StatusUnauthorized, "authentication required")
return
}
next(w, r)
}
}
func writeAdminJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeAdminError(w http.ResponseWriter, status int, message string) {
writeAdminJSON(w, status, map[string]string{"error": message})
}
func decodeAdminJSON(r *http.Request, payload interface{}) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(payload)
}
func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
var loginReq adminLoginRequest
if err := decodeAdminJSON(r, &loginReq); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid login payload")
return
}
if !verifyTOTP(currentAdminAuth.totpSecret, loginReq.Code, time.Now()) {
log.Printf("admin login failed from %s", r.RemoteAddr)
writeAdminError(w, http.StatusUnauthorized, "invalid TOTP code")
return
}
token, err := signAdminJWT(time.Now())
if err != nil {
writeAdminError(w, http.StatusInternalServerError, "could not create session token")
return
}
setAdminTokenCookie(w, token)
log.Printf("admin login succeeded from %s", r.RemoteAddr)
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminLogoutHandler(w http.ResponseWriter, r *http.Request) {
clearAdminTokenCookie(w)
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminConfigHandler(w http.ResponseWriter, r *http.Request) {
writeAdminJSON(w, http.StatusOK, hooksStateSnapshot())
}
func adminMutationStatus(err error) int {
switch {
case err == nil:
return http.StatusOK
case errors.Is(err, errAdminReadOnly):
return http.StatusConflict
case errors.Is(err, errUnknownHookFile):
return http.StatusNotFound
case strings.Contains(err.Error(), "was not found"):
return http.StatusNotFound
case strings.Contains(err.Error(), "already defined"):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func adminCreateHookHandler(w http.ResponseWriter, r *http.Request) {
var req adminHookMutationRequest
if err := decodeAdminJSON(r, &req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid hook payload")
return
}
if err := upsertHookInFile(req.File, "", req.Hook); err != nil {
writeAdminError(w, adminMutationStatus(err), err.Error())
return
}
writeAdminJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
func adminUpdateHookHandler(w http.ResponseWriter, r *http.Request) {
var req adminHookMutationRequest
if err := decodeAdminJSON(r, &req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid hook payload")
return
}
if strings.TrimSpace(req.CurrentID) == "" {
writeAdminError(w, http.StatusBadRequest, "currentId is required")
return
}
if err := upsertHookInFile(req.File, req.CurrentID, req.Hook); err != nil {
writeAdminError(w, adminMutationStatus(err), err.Error())
return
}
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminDeleteHookHandler(w http.ResponseWriter, r *http.Request) {
var req adminHookMutationRequest
if err := decodeAdminJSON(r, &req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid delete payload")
return
}
if strings.TrimSpace(req.File) == "" {
writeAdminError(w, http.StatusBadRequest, "file is required")
return
}
if strings.TrimSpace(req.CurrentID) == "" {
writeAdminError(w, http.StatusBadRequest, "currentId is required")
return
}
if err := deleteHookFromFile(req.File, req.CurrentID); err != nil {
writeAdminError(w, adminMutationStatus(err), err.Error())
return
}
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}