Cross Compile Go / build (push) Canceled after 0s
- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts - admin API: overview stats, indexing queue, addon management (priority, reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes, traffic, metrics explorer, services, events/audit log, system info - built-in metrics: request latency per route, visitors, top addons and searches, indexer/scraper counters; minute buckets in the DB with hourly rollups and retention; optional Prometheus /metrics (METRICS_TOKEN) - POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat) and a stdlib-only Go client in telemetry/ - auth hardening: empty secrets no longer allow access, constant-time compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting - Dockerfile builds the UI and no longer bakes config.yaml into the image; CI builds the UI, runs tests and builds arm64 with CGO
109 lines
2.7 KiB
Go
109 lines
2.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// CookieName is the admin session cookie. It is scoped to /admin so it is never
|
|
// sent to the public API.
|
|
const CookieName = "rc_admin"
|
|
|
|
var b64 = base64.RawURLEncoding
|
|
|
|
// The signing key is derived from the admin secret, so rotating ADMIN_SECRET
|
|
// invalidates every issued session.
|
|
func sessionKey() []byte {
|
|
m := hmac.New(sha256.New, []byte(initializers.AdminSecret))
|
|
m.Write([]byte("rc-admin-session/v1"))
|
|
return m.Sum(nil)
|
|
}
|
|
|
|
func sign(payload string) string {
|
|
m := hmac.New(sha256.New, sessionKey())
|
|
m.Write([]byte(payload))
|
|
return b64.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
// IssueToken creates a stateless session token: v1.<expiry>.<nonce>.<mac>
|
|
func IssueToken(now time.Time) (string, time.Time) {
|
|
exp := now.Add(initializers.AdminSessionTTL)
|
|
nonce := make([]byte, 16)
|
|
_, _ = rand.Read(nonce)
|
|
payload := fmt.Sprintf("v1.%d.%s", exp.Unix(), b64.EncodeToString(nonce))
|
|
return payload + "." + sign(payload), exp
|
|
}
|
|
|
|
// VerifyToken checks the signature and expiry and returns the expiry time.
|
|
func VerifyToken(token string, now time.Time) (time.Time, bool) {
|
|
if initializers.AdminSecret == "" {
|
|
return time.Time{}, false
|
|
}
|
|
idx := strings.LastIndexByte(token, '.')
|
|
if idx <= 0 {
|
|
return time.Time{}, false
|
|
}
|
|
payload, mac := token[:idx], token[idx+1:]
|
|
if !hmac.Equal([]byte(mac), []byte(sign(payload))) {
|
|
return time.Time{}, false
|
|
}
|
|
parts := strings.Split(payload, ".")
|
|
if len(parts) != 3 || parts[0] != "v1" {
|
|
return time.Time{}, false
|
|
}
|
|
expUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
exp := time.Unix(expUnix, 0)
|
|
if !now.Before(exp) {
|
|
return time.Time{}, false
|
|
}
|
|
return exp, true
|
|
}
|
|
|
|
func cookieSecure(c *gin.Context) bool {
|
|
switch initializers.AdminCookieSecure {
|
|
case "true", "1", "yes":
|
|
return true
|
|
case "false", "0", "no":
|
|
return false
|
|
}
|
|
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
|
}
|
|
|
|
func SetSessionCookie(c *gin.Context, token string, exp time.Time) {
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: CookieName,
|
|
Value: token,
|
|
Path: "/admin",
|
|
Expires: exp,
|
|
MaxAge: int(time.Until(exp).Seconds()),
|
|
HttpOnly: true,
|
|
Secure: cookieSecure(c),
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
func ClearSessionCookie(c *gin.Context) {
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: CookieName,
|
|
Value: "",
|
|
Path: "/admin",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: cookieSecure(c),
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|