mirror of
https://github.com/adnanh/webhook.git
synced 2026-07-21 17:29:49 +00:00
479 lines
13 KiB
Go
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})
|
|
}
|