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 }