- ALLOW_PURGE is now false by default; the UI shows a banner when there is no authentication. - Read-only role: AUTH_VIEWER_USER/AUTH_VIEWER_PASS in local mode, or OIDC_ADMIN_GROUP in OIDC mode; changes get 403 and the admin settings are greyed out. - Content-Security-Policy (inline scripts allowed by hash) and other security headers; cross-site changes are refused. - Syslog TCP: at most SYSLOG_TCP_MAX_CONNS connections, closed after SYSLOG_TCP_IDLE of silence; HTTP idle timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
190 lines
5.5 KiB
Go
190 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
|
|
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
|
|
// accepted so scripts calling the API keep working, but the browser popup is gone.
|
|
|
|
const loginPage = "/login.html"
|
|
|
|
var loginFailDelay = time.Second // slows down password guessing
|
|
|
|
type Local struct {
|
|
user, pass string
|
|
viewerUser string // optional read-only account
|
|
viewerPass string
|
|
ttl time.Duration
|
|
logo string // LOGIN_LOGO, served at /auth/logo
|
|
key []byte
|
|
next http.Handler
|
|
}
|
|
|
|
func newLocal(c authConfig) *Local {
|
|
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
|
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
|
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass + "\x00" + c.viewerUser + "\x00" + c.viewerPass))
|
|
if c.loginLogo != "" {
|
|
if _, err := os.Stat(c.loginLogo); err != nil {
|
|
log.Printf("auth: LOGIN_LOGO: %v", err)
|
|
}
|
|
}
|
|
log.Printf("local authentication enabled (user %s)", c.user)
|
|
if c.viewerUser != "" {
|
|
log.Printf("local read-only account enabled (user %s)", c.viewerUser)
|
|
}
|
|
return &Local{user: c.user, pass: c.pass, viewerUser: c.viewerUser, viewerPass: c.viewerPass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
|
|
}
|
|
|
|
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/healthz", "/style.css":
|
|
l.next.ServeHTTP(w, r)
|
|
return
|
|
case "/auth/logo":
|
|
l.serveLogo(w, r)
|
|
return
|
|
case "/auth/login":
|
|
l.handleLogin(w, r)
|
|
return
|
|
case "/auth/logout":
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
|
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
|
return
|
|
}
|
|
s, ok := l.sessionUser(r)
|
|
if !ok {
|
|
if u, p, basic := r.BasicAuth(); basic {
|
|
if viewer, valid := l.check(u, p); valid {
|
|
s, ok = session{User: u, Viewer: viewer}, true
|
|
}
|
|
}
|
|
}
|
|
if ok && s.Viewer {
|
|
r = asViewer(r)
|
|
}
|
|
switch {
|
|
case r.URL.Path == loginPage:
|
|
if ok {
|
|
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
l.next.ServeHTTP(w, r)
|
|
case ok && r.URL.Path == "/auth/me":
|
|
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": s.User, "role": roleName(s.Viewer)})
|
|
case ok:
|
|
l.next.ServeHTTP(w, r)
|
|
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
|
target := loginPage
|
|
if ret := r.URL.RequestURI(); ret != "/" {
|
|
target += "?" + url.Values{"r": {ret}}.Encode()
|
|
}
|
|
http.Redirect(w, r, target, http.StatusFound)
|
|
default:
|
|
writeAuthRequired(w)
|
|
}
|
|
}
|
|
|
|
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
|
return
|
|
}
|
|
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
|
ret := safeReturn(r.PostFormValue("r"))
|
|
viewer, valid := l.check(user, pass)
|
|
if !valid {
|
|
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
|
time.Sleep(loginFailDelay)
|
|
q := url.Values{"e": {"1"}}
|
|
if ret != "/" {
|
|
q.Set("r", ret)
|
|
}
|
|
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
|
return
|
|
}
|
|
log.Printf("auth: %s logged in from %s (%s)", user, clientIP(r), roleName(viewer))
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix(), Viewer: viewer}),
|
|
Path: "/",
|
|
MaxAge: int(l.ttl.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: isHTTPS(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, ret, http.StatusSeeOther)
|
|
}
|
|
|
|
func (l *Local) sessionUser(r *http.Request) (session, bool) {
|
|
var s session
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
|
return session{}, false
|
|
}
|
|
return s, true
|
|
}
|
|
|
|
// check validates a user and password: the admin account, or the read-only one
|
|
// (viewer=true) when AUTH_VIEWER_USER is set.
|
|
func (l *Local) check(user, pass string) (viewer, ok bool) {
|
|
if same(user, l.user) && same(pass, l.pass) {
|
|
return false, true
|
|
}
|
|
if l.viewerUser != "" && same(user, l.viewerUser) && same(pass, l.viewerPass) {
|
|
return true, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// same compares in constant time, so the answer time says nothing of the secret.
|
|
func same(a, b string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
|
|
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
|
|
if l.logo == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
http.ServeFile(w, r, l.logo)
|
|
}
|
|
|
|
// safeReturn keeps the page to open after login inside logstream.
|
|
func safeReturn(ret string) string {
|
|
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
|
|
return "/"
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
|
|
func isHTTPS(r *http.Request) bool {
|
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
|
return strings.TrimSpace(strings.Split(f, ",")[0])
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|