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() }