412 lines
15 KiB
Go
412 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type session struct {
|
|
expiresAt time.Time
|
|
csrfToken string
|
|
}
|
|
|
|
// loginAttempt tracks failed login attempts for rate limiting.
|
|
type loginAttempt struct {
|
|
count int
|
|
lastFail time.Time
|
|
}
|
|
|
|
const (
|
|
sessionCookieName = "felhom_session"
|
|
sessionMaxAge = 7 * 24 * time.Hour
|
|
loginMaxAttempts = 5
|
|
loginWindowDuration = 1 * time.Minute
|
|
)
|
|
|
|
// effectivePasswordHash returns the active password hash using the priority:
|
|
// 1. settings.json → password_hash (customer changed it)
|
|
// 2. controller.yaml → web.password_hash (operator provisioned)
|
|
// 3. Empty string → no auth required
|
|
func (s *Server) effectivePasswordHash() string {
|
|
if s.settings != nil {
|
|
if h := s.settings.GetPasswordHash(); h != "" {
|
|
return h
|
|
}
|
|
}
|
|
return s.cfg.Web.PasswordHash
|
|
}
|
|
|
|
// authEnabled returns true if a password is configured from any source.
|
|
func (s *Server) authEnabled() bool {
|
|
return s.effectivePasswordHash() != ""
|
|
}
|
|
|
|
// RequireAuth returns middleware that checks for valid session or shows login.
|
|
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Customer-claim gate (v0.122.0, F-4): an unclaimed box with a delivered code hash and no
|
|
// password serves ONLY the claim page + its assets; everything else → claim page / 401.
|
|
// The claim routes (/claim, /claim/request-new-code) are handled by the mux — let them
|
|
// through so serveClaimGate only intercepts the GATED paths. A set password disables the
|
|
// gate entirely (claimGateActive returns false → the normal auth path below runs).
|
|
if s.claimGateActive() {
|
|
if claimPageAllowedPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] claim gate: intercepting %s %s (unclaimed)", r.Method, r.URL.Path)
|
|
}
|
|
s.serveClaimGate(w, r)
|
|
return
|
|
}
|
|
|
|
// Skip auth if no password is configured (legacy-open transition state, or claim disabled).
|
|
if !s.authEnabled() {
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if r.URL.Path == "/api/health" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// The anonymous language switch (v0.254.0, R-557). The sign-in page shows a globe, so the
|
|
// globe has to work without a session. It writes ONE display-only cookie in the visitor's own
|
|
// browser and touches nothing the household owns — langCookieHandler carries the reasoning.
|
|
if r.URL.Path == langCookiePath && r.Method == http.MethodPost {
|
|
s.langCookieHandler(w, r)
|
|
return
|
|
}
|
|
|
|
// Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET
|
|
// entry (code-gated internally). Static assets for the page too. The guest launcher share
|
|
// (v0.165.0) joins here — /s/<token> is a capability URL with NO admin session; the token
|
|
// (or the optional share password) is its own gate. Placed AFTER the claim-gate block above,
|
|
// so an unclaimed box never serves the guest page (the claim gate stays supreme).
|
|
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/s/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if r.URL.Path == "/login" && r.Method == http.MethodPost {
|
|
s.handleLogin(w, r)
|
|
return
|
|
}
|
|
if r.URL.Path == "/login" {
|
|
s.renderLogin(w, r, "", s.flashFrom(r, "flash"))
|
|
return
|
|
}
|
|
if r.URL.Path == "/logout" {
|
|
s.handleLogout(w, r)
|
|
return
|
|
}
|
|
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil || !s.isValidSession(cookie.Value) {
|
|
if s.isDebug() {
|
|
reason := "no cookie"
|
|
if err == nil {
|
|
reason = "invalid/expired session"
|
|
}
|
|
s.logger.Printf("[DEBUG] [web] auth: rejected %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
s.logger.Printf("[WARN] [api] Unauthorized request to %s from %s", r.URL.Path, r.RemoteAddr)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`)
|
|
return
|
|
}
|
|
// Redirect to login with ?next= so we can return to the original page
|
|
loginURL := "/login"
|
|
if r.URL.Path != "/" && r.URL.Path != "/dashboard" {
|
|
loginURL = "/login?next=" + url.QueryEscape(r.URL.RequestURI())
|
|
}
|
|
http.Redirect(w, r, loginURL, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] auth: valid session for %s %s", r.Method, r.URL.Path)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
password := r.FormValue("password")
|
|
nextURL := r.FormValue("next")
|
|
|
|
// The counter's key is the VISITOR (clientaddr.go, R-753): through the tunnel each visitor has its own address, so a
|
|
// stranger's wrong passwords lock only the stranger. Before v0.286.0 every tunnel visitor shared cloudflared's
|
|
// address here, and five wrong tries locked the whole household out of its own dashboard for a minute.
|
|
ip := rateKey(r)
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] login attempt: visitor %s (peer %s, X-Forwarded-For %q, CF-Connecting-IP %q)",
|
|
ip, r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("CF-Connecting-IP"))
|
|
}
|
|
|
|
if password == "" {
|
|
s.renderLogin(w, r, s.msg(r, "login.msg.empty_password"), "")
|
|
return
|
|
}
|
|
|
|
// R-983 (v0.324.0): the try is RESERVED before the password is compared, under the one lock — check and count
|
|
// are a single step. Before, the count was taken only after the slow compare, so N parallel tries all passed the
|
|
// check and the 5-per-minute limit became "as many as arrive at once". A right password clears the counter below,
|
|
// so the household loses nothing by its own try being counted first. Pinned by TestR983_ParallelLoginsHitTheLimit.
|
|
if n, ok := s.reserveLoginAttempt(ip); !ok {
|
|
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, n)
|
|
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
|
|
return
|
|
}
|
|
|
|
effectiveHash := s.effectivePasswordHash()
|
|
if err := s.comparePassword([]byte(effectiveHash), []byte(password)); err != nil {
|
|
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
|
|
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
|
|
return
|
|
}
|
|
|
|
// Successful login — clear rate limit for this IP
|
|
s.loginAttemptMu.Lock()
|
|
delete(s.loginAttempts, ip)
|
|
s.loginAttemptMu.Unlock()
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] login successful from %s, creating session", ip)
|
|
}
|
|
|
|
token := s.createSession()
|
|
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(sessionMaxAge.Seconds()),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: isSecure,
|
|
})
|
|
|
|
// R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder
|
|
// is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the
|
|
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
|
|
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
|
|
|
|
s.logger.Printf("[INFO] [web] Login from %s", ip)
|
|
|
|
// Redirect to ?next= target if provided, otherwise to dashboard. R-983 (v0.324.0): through safeBackPath, the one
|
|
// guard for a destination taken from a request — a bare HasPrefix("/") let "//host" and "/\host" send a browser
|
|
// that had just signed in to another site. Pinned by TestR983_LoginNextStaysOnTheBox.
|
|
http.Redirect(w, r, safeBackPath(nextURL), http.StatusFound)
|
|
}
|
|
|
|
// reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it
|
|
// may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a
|
|
// check that none of them has counted yet. A window that has passed starts clean.
|
|
func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
|
|
s.loginAttemptMu.Lock()
|
|
defer s.loginAttemptMu.Unlock()
|
|
if s.loginAttempts == nil {
|
|
s.loginAttempts = map[string]*loginAttempt{}
|
|
}
|
|
a := s.loginAttempts[ip]
|
|
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
|
|
a = nil
|
|
}
|
|
if a == nil {
|
|
a = &loginAttempt{}
|
|
s.loginAttempts[ip] = a
|
|
}
|
|
if a.count >= loginMaxAttempts {
|
|
return a.count, false
|
|
}
|
|
a.count++
|
|
a.lastFail = time.Now()
|
|
return a.count, true
|
|
}
|
|
|
|
// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable).
|
|
func (s *Server) comparePassword(hash, pw []byte) error {
|
|
if s.passwordCompare != nil {
|
|
return s.passwordCompare(hash, pw)
|
|
}
|
|
return bcrypt.CompareHashAndPassword(hash, pw)
|
|
}
|
|
|
|
// handleLogout ends the presented dashboard session. It runs inside RequireAuth, BEFORE CsrfProtect, so it checks the
|
|
// session's CSRF token itself (R-983, v0.324.0): without it any page — another site, or an app on a sibling
|
|
// subdomain, which is same-site and so gets the Lax cookie — could sign the household out. A GET changes nothing and
|
|
// goes to the dashboard; the dashboard's own sign-out control is a POST form carrying the token (layout.html).
|
|
// A POST with no valid session has nothing to end and only clears the cookie. Pinned by TestR983_LogoutNeedsCSRF.
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
|
|
}
|
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
|
if expected := s.csrfTokenForSession(cookie.Value); expected != "" {
|
|
submitted := r.FormValue(csrfFormField)
|
|
if submitted == "" {
|
|
submitted = r.Header.Get(csrfHeaderName)
|
|
}
|
|
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
|
|
s.csrfReject(w, r, "logout without the session's token")
|
|
return
|
|
}
|
|
}
|
|
s.sessionsMu.Lock()
|
|
delete(s.sessions, sessionFingerprint(cookie.Value))
|
|
s.saveSessionsRevokingLocked() // R-35: a signed-out cookie stays signed out across a restart
|
|
s.sessionsMu.Unlock()
|
|
}
|
|
s.logger.Printf("[INFO] [web] User logged out from %s", r.RemoteAddr)
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) createSession() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
token := hex.EncodeToString(b)
|
|
|
|
csrfB := make([]byte, 32)
|
|
_, _ = rand.Read(csrfB)
|
|
csrfToken := hex.EncodeToString(csrfB)
|
|
|
|
s.sessionsMu.Lock()
|
|
// R-35: keyed by the fingerprint, never the cookie value — this map is what session_store.go writes to disk.
|
|
s.sessions[sessionFingerprint(token)] = &session{
|
|
expiresAt: time.Now().Add(sessionMaxAge),
|
|
csrfToken: csrfToken,
|
|
}
|
|
_ = s.saveSessionsLocked()
|
|
sessionCount := len(s.sessions)
|
|
s.sessionsMu.Unlock()
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] session created, expires=%s, active_sessions=%d", time.Now().Add(sessionMaxAge).Format(time.RFC3339), sessionCount)
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
// csrfTokenForSession returns the CSRF token for the given session cookie value.
|
|
// Returns "" if the session is invalid or expired.
|
|
func (s *Server) csrfTokenForSession(sessionToken string) string {
|
|
s.sessionsMu.RLock()
|
|
defer s.sessionsMu.RUnlock()
|
|
sess, ok := s.sessions[sessionFingerprint(sessionToken)]
|
|
if !ok || time.Now().After(sess.expiresAt) {
|
|
return ""
|
|
}
|
|
return sess.csrfToken
|
|
}
|
|
|
|
func (s *Server) isValidSession(token string) bool {
|
|
s.sessionsMu.RLock()
|
|
defer s.sessionsMu.RUnlock()
|
|
sess, ok := s.sessions[sessionFingerprint(token)]
|
|
return ok && time.Now().Before(sess.expiresAt)
|
|
}
|
|
|
|
// invalidateAllSessions clears all sessions, forcing re-login.
|
|
// Used after password change.
|
|
func (s *Server) invalidateAllSessions() {
|
|
s.sessionsMu.Lock()
|
|
count := len(s.sessions)
|
|
s.sessions = make(map[string]*session)
|
|
s.saveSessionsRevokingLocked() // R-35: the password change ends every session on disk too
|
|
s.sessionsMu.Unlock()
|
|
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
|
|
}
|
|
|
|
// endEverySession is what a dashboard password change and a claim/reset do (R-976, v0.324.0): every dashboard session
|
|
// ends (invalidateAllSessions), every family session — each member's and the household's — ends, and the gate key is
|
|
// replaced, so every setup-gate pass and every family app cookie stops opening its app at the next request. Before,
|
|
// only the dashboard sessions ended, so a session copied out of a family app kept working for up to 30 days after the
|
|
// household had changed its password. Family members keep their own passwords and sign in again; the household's
|
|
// browser passes again through its new dashboard session. Pinned by TestR976_PasswordChangeEndsFamilyAndGateSessions
|
|
// and TestR976_ClaimResetEndsFamilyAndGateSessions.
|
|
func (s *Server) endEverySession(why string) {
|
|
s.invalidateAllSessions()
|
|
n, err := s.familyStore().EndAll()
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] %s: the family sessions were ended but the family list could not be saved: %v", why, err)
|
|
}
|
|
s.rotateGateKey()
|
|
s.logger.Printf("[INFO] [web] %s: every dashboard session, %d family session(s) and every app gate pass ended", why, n)
|
|
}
|
|
|
|
func (s *Server) cleanupSessions() {
|
|
ticker := time.NewTicker(15 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
case <-ticker.C:
|
|
s.sessionsMu.Lock()
|
|
now := time.Now()
|
|
expired := 0
|
|
for t, sess := range s.sessions {
|
|
if now.After(sess.expiresAt) {
|
|
delete(s.sessions, t)
|
|
expired++
|
|
}
|
|
}
|
|
remaining := len(s.sessions)
|
|
if expired > 0 {
|
|
_ = s.saveSessionsLocked()
|
|
}
|
|
s.sessionsMu.Unlock()
|
|
if expired > 0 {
|
|
s.logger.Printf("[INFO] [web] Cleaned up %d expired sessions, %d remaining", expired, remaining)
|
|
}
|
|
if s.isDebug() && expired > 0 {
|
|
s.logger.Printf("[DEBUG] [web] session cleanup: expired=%d remaining=%d", expired, remaining)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close signals the server to stop background goroutines. Safe to call multiple times.
|
|
func (s *Server) Close() {
|
|
s.closeOnce.Do(func() {
|
|
close(s.done)
|
|
})
|
|
}
|
|
|
|
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) {
|
|
data := map[string]interface{}{
|
|
"Title": "Bejelentkezés",
|
|
"CustomerName": s.cfg.Customer.Name,
|
|
"Version": s.version, // logo ?v= cache-bust (v0.166.0)
|
|
"Error": errorMsg,
|
|
"Flash": flashMsg,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.executeTemplateLang(w, r, "login", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (login): %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|