Files
nmon/internal/auth/middleware.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
Make nmon easier to install and run without editing YAML by hand:

- Settings: point-and-click forms over config.yaml (GET/PUT
  /api/v1/settings/form); the YAML editor moves to /settings/yaml.
- First install: with no sign-in configured nmon starts in install
  mode, serves only the setup wizard (sign-in, basics, devices,
  alerting) and closes it for good once sign-in is saved. A
  self-signed certificate is generated when no TLS source is given.
  poller.interval becomes a config setting.
- Reverse proxies: opt-in --http-listen plain-HTTP listener beside
  HTTPS; sign-in stays required.
- Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets,
  template preview and test send.
- Help: the operations, coverage and profile docs served at /help.
- Polling: per-device "Poll now", a dry-run test poll returning an
  SNMP trace, and time-limited in-memory SNMP debug recording, in the
  UI and as `nmon device poll`.
- Ansible: deploys into install mode when no sign-in is set, and no
  longer overwrites the config.yaml the wizard wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 06:31:12 +00:00

149 lines
4.9 KiB
Go

package auth
import (
"crypto/subtle"
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
)
// CSRFHeader carries the per-session CSRF token on state-changing cookie requests.
const CSRFHeader = "X-CSRF-Token"
// Middleware authenticates and authorises every request, then passes the Principal down in the context.
// In order it: resolves the client address (honouring X-Forwarded-* only from a trusted proxy); lets
// ingest paths through untouched (ingest authenticates push tokens itself); lets Public routes through;
// authenticates a bearer API token or a cookie session; requires the CSRF token on a state-changing
// cookie request; and checks the route Policy's minimum role. Push tokens get 403 here.
func (a *Auth) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
client := a.proxies.Load().Resolve(r)
if !client.ViaProxy {
scrub(r)
}
r = r.WithContext(withClient(r.Context(), client))
if hasPrefix(r.URL.Path, a.opts.IngestPrefixes) {
next.ServeHTTP(w, r)
return
}
ru := a.opts.Policy.lookup(r)
if ru.public {
next.ServeHTTP(w, r)
return
}
if a.opts.Install {
a.serveInstall(w, r, ru, next)
return
}
if a.opts.DevOpen {
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), devPrincipal())))
return
}
p, err := a.authenticate(r)
if err != nil {
a.deny(w, r, err)
return
}
if !p.Role.AtLeast(ru.min) {
writeProblem(w, http.StatusForbidden, "requires role "+string(ru.min))
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
})
}
// serveInstall is the install-mode gate: the setup routes and the browser pages (the UI that hosts the wizard) are
// served to anyone as an admin; every other API route answers 503 with a problem, so a half-installed nmon never
// exposes the rest of the API without a sign-in.
func (a *Auth) serveInstall(w http.ResponseWriter, r *http.Request, ru rule, next http.Handler) {
if !ru.install && !a.isNavigation(r) {
w.Header().Set("Retry-After", "60")
writeProblem(w, http.StatusServiceUnavailable, "setup not finished")
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), installPrincipal())))
}
// authenticate resolves the caller from a bearer token or a cookie session and applies CSRF.
func (a *Auth) authenticate(r *http.Request) (*Principal, error) {
if secret, ok := bearer(r); ok {
return a.Tokens.Authenticate(r.Context(), secret)
}
p, err := a.Sessions.Load(r.Context(), r)
if err != nil {
return nil, err
}
if !IsSafeMethod(r.Method) && !validCSRF(p.CSRF, r.Header.Get(CSRFHeader)) {
return nil, ErrCSRF
}
return p, nil
}
func validCSRF(want, got string) bool {
return want != "" && subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1
}
func (a *Auth) deny(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrForbidden):
writeProblem(w, http.StatusForbidden, "this token is not valid for this API")
case errors.Is(err, ErrCSRF):
writeProblem(w, http.StatusForbidden, "missing or wrong "+CSRFHeader)
case errors.Is(err, ErrUnauthenticated):
if a.isNavigation(r) {
http.Redirect(w, r, a.signInURL(r), http.StatusFound)
return
}
w.Header().Set("WWW-Authenticate", `Bearer realm="nmon"`)
writeProblem(w, http.StatusUnauthorized, "authentication required")
default:
a.opts.Logger.Error("authenticate", slog.Any("err", err))
writeProblem(w, http.StatusInternalServerError, "authentication failed")
}
}
// bearer extracts a token from "Authorization: Bearer x" or the password of Basic auth (what
// Prometheus-style clients send). Anything else is not a token request.
func bearer(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if v, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(v), true
}
if strings.HasPrefix(h, "Basic ") {
if _, pw, ok := r.BasicAuth(); ok {
return pw, true
}
}
return "", false
}
// isNavigation reports whether r is a browser page load (the SPA, its files, /docs) rather than an API call: those
// are sent to sign in, while API calls get a 401 problem. A request that carried a bearer token is an API client.
func (a *Auth) isNavigation(r *http.Request) bool {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
if _, ok := bearer(r); ok {
return false
}
p := r.URL.Path
for _, api := range []string{"/api/", "/auth/", "/metrics", "/openapi", "/schemas"} {
if strings.HasPrefix(p, api) {
return false
}
}
return true
}
// signInURL is where an unauthenticated page load goes: the IdP flow, or the break-glass prompt when no IdP is
// configured. The original URL travels in ?return= and is validated as a same-site path when it is used.
func (a *Auth) signInURL(r *http.Request) string {
target := "/auth/login"
if a.OIDC == nil {
target = "/auth/breakglass"
}
return target + "?return=" + url.QueryEscape(r.URL.RequestURI())
}