Files
cloud-ip-validator/internal/dashboard/auth.go
T
ayurishchevandClaude Sonnet 5.5 debf2afed2 Add authentication: admin/agent bearer tokens for the API, login for the dashboard
control-api: every route now carries a mandatory access level (admin / agent /
open) in a route table. All /api/v1/admin/* require the admin token; the
write calls of validator-agent and prober (self-check, events, results,
complete) require a separate static agent token; register, heartbeat and
fetching the assignment stay open. Tokens come from env vars, are compared in
constant time and never logged. An empty token leaves that level open with a
startup warning (backward compatible).

validator-agent / prober: apiclient sends the agent token only to control-api.

admin-dashboard: login/password (from env) with a stateless HMAC session
cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for
htmx polls, logout in the sidebar; the dashboard calls control-api with the
admin token. Login page layout fixed after review.

Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples,
e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD,
README), plan and review under docs/changes/, bin/ rebuilt with new
SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 11:35:24 +03:00

393 lines
11 KiB
Go

package dashboard
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"log/slog"
"net"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"sync"
"time"
)
const (
sessionCookieName = "session"
defaultSessionTTL = 8 * time.Hour
// Brute-force throttle: maxLoginFailures failures from one client IP
// within loginFailureWindow lock that IP out until the oldest failure
// leaves the window.
maxLoginFailures = 5
loginFailureWindow = 10 * time.Minute
)
// authState holds everything the login/session machinery needs. A nil-safe
// zero value is never used: Server.auth is always set by New, with
// enabled=false when no credentials were configured.
type authState struct {
enabled bool
user [sha256.Size]byte // sha256(username)
pass [sha256.Size]byte // sha256(password)
key []byte // HMAC key for session cookies
ttl time.Duration
now func() time.Time
throttle *loginThrottle
}
func newAuthState(cfg Config, log *slog.Logger) *authState {
a := &authState{now: time.Now, throttle: newLoginThrottle(), ttl: cfg.SessionTTL}
if a.ttl <= 0 {
a.ttl = defaultSessionTTL
}
if cfg.Username == "" || cfg.Password == "" {
log.Warn("dashboard login is disabled: username/password are not set, anyone who can reach this port has full access")
return a
}
a.enabled = true
a.user = sha256.Sum256([]byte(cfg.Username))
a.pass = sha256.Sum256([]byte(cfg.Password))
if cfg.SessionSecret != "" {
a.key = []byte(cfg.SessionSecret)
} else {
a.key = make([]byte, 32)
if _, err := rand.Read(a.key); err != nil {
panic("dashboard: crypto/rand failed: " + err.Error())
}
log.Warn("dashboard session secret is not set: using a random one, sessions are reset on every restart")
}
return a
}
// checkCredentials compares both fields in constant time and always
// evaluates both, so neither the length nor which field was wrong leaks.
func (a *authState) checkCredentials(user, pass string) bool {
u := sha256.Sum256([]byte(user))
p := sha256.Sum256([]byte(pass))
uOK := subtle.ConstantTimeCompare(u[:], a.user[:])
pOK := subtle.ConstantTimeCompare(p[:], a.pass[:])
return uOK&pOK == 1
}
// ---- session cookie ----
type sessionPayload struct {
User string `json:"u"`
Exp int64 `json:"exp"`
}
func (a *authState) sign(msg string) []byte {
m := hmac.New(sha256.New, a.key)
m.Write([]byte(msg))
return m.Sum(nil)
}
func (a *authState) issue(user string) (value string, exp time.Time) {
exp = a.now().Add(a.ttl)
raw, _ := json.Marshal(sessionPayload{User: user, Exp: exp.Unix()})
p := base64.RawURLEncoding.EncodeToString(raw)
return p + "." + base64.RawURLEncoding.EncodeToString(a.sign(p)), exp
}
// verify returns the session's user if value is an untampered, unexpired
// cookie issued with this server's key.
func (a *authState) verify(value string) (string, bool) {
p, sig, ok := strings.Cut(value, ".")
if !ok {
return "", false
}
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, a.sign(p)) {
return "", false
}
raw, err := base64.RawURLEncoding.DecodeString(p)
if err != nil {
return "", false
}
var sp sessionPayload
if json.Unmarshal(raw, &sp) != nil || sp.User == "" || a.now().Unix() >= sp.Exp {
return "", false
}
return sp.User, true
}
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (a *authState) setCookie(w http.ResponseWriter, r *http.Request, user string) {
value, exp := a.issue(user)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: value, Path: "/", Expires: exp,
MaxAge: int(a.ttl.Seconds()), HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
})
}
func clearCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
})
}
// ---- request context ----
type ctxKey struct{}
func userFromRequest(r *http.Request) string {
u, _ := r.Context().Value(ctxKey{}).(string)
return u
}
// ---- middleware ----
// authMiddleware gates every route except the login page, logout and static
// assets. With login disabled it is a pass-through (no CSRF check either).
func (s *Server) authMiddleware(next http.Handler) http.Handler {
a := s.auth
if !a.enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !safeMethod(r.Method) && !sameOrigin(r) {
s.Log.Warn("rejected cross-origin request", "method", r.Method, "path", r.URL.Path)
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
if isOpenPath(r) {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if user, ok := a.verify(c.Value); ok {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, user)))
return
}
}
// htmx polls fragments in the background: answer with an
// HX-Redirect instead of a redirect whose login page would be
// swapped into the fragment.
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
target := "/login"
if r.Method == http.MethodGet {
if n := r.URL.RequestURI(); n != "/" {
target += "?next=" + url.QueryEscape(n)
}
}
http.Redirect(w, r, target, http.StatusSeeOther)
})
}
func isOpenPath(r *http.Request) bool {
switch r.URL.Path {
case "/login":
return r.Method == http.MethodGet || r.Method == http.MethodPost
case "/logout":
return r.Method == http.MethodPost
}
return strings.HasPrefix(r.URL.Path, "/static/") && (r.Method == http.MethodGet || r.Method == http.MethodHead)
}
func safeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
// sameOrigin implements the CSRF check: Origin (or, when absent, Referer)
// must name this very host. Browsers always send Origin on cross-site
// POSTs; a request with neither header is refused.
func sameOrigin(r *http.Request) bool {
h := r.Header.Get("Origin")
if h == "" {
h = r.Header.Get("Referer")
}
if h == "" || h == "null" {
return false
}
u, err := url.Parse(h)
if err != nil || u.Host == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
// safeNext returns next only if it is a same-origin relative path, so the
// post-login redirect can never leave the site.
func safeNext(next string) string {
if next == "" || next[0] != '/' || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/"
}
for _, c := range next {
if c < 0x20 || c == 0x7f || c == '\\' {
return "/"
}
}
u, err := url.Parse(next)
if err != nil || u.Scheme != "" || u.Host != "" {
return "/"
}
return next
}
// ---- brute-force throttle ----
type loginThrottle struct {
mu sync.Mutex
failures map[string][]time.Time
}
func newLoginThrottle() *loginThrottle {
return &loginThrottle{failures: map[string][]time.Time{}}
}
// prune drops expired failures (all clients) — caller holds mu. The map is
// bounded by the number of distinct IPs that failed in the last window.
func (t *loginThrottle) prune(now time.Time) {
for ip, fs := range t.failures {
i := 0
for i < len(fs) && now.Sub(fs[i]) >= loginFailureWindow {
i++
}
if i == len(fs) {
delete(t.failures, ip)
} else if i > 0 {
t.failures[ip] = fs[i:]
}
}
}
// blocked reports whether ip is locked out, and for how long.
func (t *loginThrottle) blocked(ip string, now time.Time) (bool, time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.prune(now)
fs := t.failures[ip]
if len(fs) < maxLoginFailures {
return false, 0
}
return true, fs[0].Add(loginFailureWindow).Sub(now)
}
func (t *loginThrottle) fail(ip string, now time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
t.prune(now)
t.failures[ip] = append(t.failures[ip], now)
}
func (t *loginThrottle) clear(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.failures, ip)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// ---- handlers ----
type loginPageData struct {
Error string
Next string
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, data loginPageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := s.tmpl.ExecuteTemplate(w, "login_page", data); err != nil {
s.Log.Error("render login", "err", err)
}
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if !s.auth.enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if _, ok := s.auth.verify(c.Value); ok {
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
return
}
}
s.renderLogin(w, http.StatusOK, loginPageData{Next: safeNext(r.URL.Query().Get("next"))})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
a := s.auth
if !a.enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
next := safeNext(r.PostForm.Get("next"))
ip := clientIP(r)
now := a.now()
if blocked, wait := a.throttle.blocked(ip, now); blocked {
secs := int(wait.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(secs))
s.Log.Warn("login throttled", "remote", ip)
s.renderLogin(w, http.StatusTooManyRequests, loginPageData{Next: next, Error: "Слишком много попыток входа. Повторите позже."})
return
}
user := r.PostForm.Get("username")
if !a.checkCredentials(user, r.PostForm.Get("password")) {
a.throttle.fail(ip, now)
s.Log.Warn("login failed", "remote", ip)
s.renderLogin(w, http.StatusOK, loginPageData{Next: next, Error: "Неверный логин или пароль"})
return
}
a.throttle.clear(ip)
a.setCookie(w, r, user)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
clearCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// withAuthInfo returns a copy of the page-data struct data with its embedded
// PageData's AuthEnabled/User filled in, so the sidebar can show the logout
// control. Data without an embedded PageData is returned unchanged.
func (s *Server) withAuthInfo(r *http.Request, data interface{}) interface{} {
if !s.auth.enabled || data == nil {
return data
}
v := reflect.ValueOf(data)
if v.Kind() != reflect.Struct {
return data
}
cp := reflect.New(v.Type()).Elem()
cp.Set(v)
pd := cp.FieldByName("PageData")
if !pd.IsValid() || pd.Type() != reflect.TypeOf(PageData{}) {
return data
}
pd.FieldByName("AuthEnabled").SetBool(true)
pd.FieldByName("User").SetString(userFromRequest(r))
return cp.Interface()
}