392 lines
11 KiB
Go
392 lines
11 KiB
Go
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()
|
||
|
|
}
|