Ctrl K
ComunidadGuías…

internal/server/auth.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package server

import (
	"bytes"
	"crypto/subtle"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"armdash/internal/auth"
	"armdash/internal/config"
	"armdash/internal/system"
)

const (
	authUserKey   = "core.auth_user"
	authHashKey   = "core.auth_password_hash"
	authModeKey   = "core.auth_mode"
	sessionCookie = "armdash_session"

	maxFailures = 10
	failWindow  = 15 * time.Minute
	lockFor     = 15 * time.Minute
)

// The login is one user name and one hash, from an env file or saved on the
// settings page. Without one there is nothing to log in to: a fresh install
// with a data directory is in setup, where /settings asks for the login
// first, and one without a data directory cannot change anything.
func (s *Server) loginPair() (user, hash string) {
	return s.cfg.Get(authUserKey), s.cfg.Get(authHashKey)
}

// hasLogin is false with the login off, whatever is stored: the stored login
// is kept for when it is switched back on.
func (s *Server) hasLogin() bool {
	u, h := s.loginPair()
	return !s.loginOff && u != "" && h != ""
}

// setupMode is a fresh install: no login anywhere, and somewhere to save one.
func (s *Server) setupMode() bool { return !s.loginOff && !s.hasLogin() && s.cfg.Writable() }

// LoginOff reports AD_CORE_AUTH_MODE=none, for the warning at startup.
func (s *Server) LoginOff() bool { return s.loginOff }

// authMode reports whether the login is off. Anything but the two known
// values refuses to start: a typo must not decide whether there is a login.
func authMode(cfg *config.Config) (off bool, err error) {
	switch m := cfg.Get(authModeKey); m {
	case "", "login":
		return false, nil
	case "none":
		return true, nil
	default:
		return false, fmt.Errorf("%s: unknown mode %q, want login or none", config.EnvName(authModeKey), m)
	}
}

// checkLogin stops startup on half a login or a damaged hash, rather than
// quietly refusing every attempt later. With the login off, a login in an env
// file contradicts it in the same place; one stored from the page is ignored.
func checkLogin(cfg *config.Config, off bool) error {
	if off {
		for _, k := range []string{authUserKey, authHashKey} {
			if src := cfg.Locked(k); src != "" {
				return fmt.Errorf("%s=none turns the login off, but %s is set in %s: remove one of them",
					config.EnvName(authModeKey), config.EnvName(k), src)
			}
		}
		return nil
	}
	user, hash := cfg.Get(authUserKey), cfg.Get(authHashKey)
	if user == "" && hash == "" {
		return nil
	}
	if user == "" || hash == "" {
		return fmt.Errorf("%s and %s must be set together",
			config.EnvName(authUserKey), config.EnvName(authHashKey))
	}
	if err := auth.Check(hash); err != nil {
		return fmt.Errorf("%s: %w", config.EnvName(authHashKey), err)
	}
	return nil
}

// withSession marks the request for system.CanEdit when it carries a live
// session, or always with the login off. Every request passes through here,
// pages and API alike.
func (s *Server) withSession(r *http.Request) *http.Request {
	if s.loginOff {
		return system.AllowEdit(r)
	}
	if !s.hasLogin() {
		return r
	}
	if c, err := r.Cookie(sessionCookie); err == nil && s.sessions.Valid(c.Value) {
		return system.AllowEdit(r)
	}
	return r
}

// requireLogin sends a visitor who is not logged in to the login page and
// reports whether it did. Without a login there is nobody to ask for.
func (s *Server) requireLogin(w http.ResponseWriter, r *http.Request) bool {
	if !s.hasLogin() || system.CanEdit(r) {
		return false
	}
	http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
	return true
}

func safeMethod(m string) bool {
	return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}

// refuse answers a write that is not allowed. htmx gets the notice component,
// so an upload form shows why in its message area; anything else plain text.
func (s *Server) refuse(w http.ResponseWriter, r *http.Request, code int, title, body string) {
	if r.Header.Get("HX-Request") == "true" {
		var buf bytes.Buffer
		err := s.tmpl.ExecuteTemplate(&buf, "notice", map[string]any{"Kind": "danger", "Title": title, "Body": body})
		if err == nil {
			w.Header().Set("Content-Type", "text/html; charset=utf-8")
			w.WriteHeader(code)
			_, _ = buf.WriteTo(w)
			return
		}
	}
	http.Error(w, title+": "+body, code)
}

// localPath keeps the return address after a login on this site. "//host" and
// "/\host" are read by browsers as another host, and they drop tabs and
// newlines before parsing, so "/\t/host" is one too.
func localPath(next string) string {
	if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") ||
		strings.HasPrefix(next, "/login") ||
		strings.ContainsFunc(next, func(c rune) bool { return c < 0x20 || c == 0x7f }) {
		return "/"
	}
	u, err := url.Parse(next)
	if err != nil || u.Scheme != "" || u.Host != "" {
		return "/"
	}
	return next
}

// clientAddr is the peer address, or behind a trusted proxy the client it
// names. X-Forwarded-For from anybody else is never read: a header anyone can
// set would let a guesser pick a fresh address for every attempt.
func (s *Server) clientAddr(r *http.Request) string { return s.trusted.Client(r) }

type loginData struct {
	Configured, Setup bool
	Next, User, Err   string
}

func (t *tree) renderLogin(w http.ResponseWriter, r *http.Request, code int, d loginData) {
	d.Configured = t.hasLogin()
	d.Setup = t.setupMode()
	var body bytes.Buffer
	if err := t.tmpl.ExecuteTemplate(&body, "login", d); err != nil {
		t.log.Error("login render failed", "err", err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	l := t.layout(r, nil, "")
	l.PageTitle = "Log in"
	l.LoginActive = true
	l.Here = d.Next
	l.Body = template.HTML(body.String())
	t.renderCode(w, code, l)
}

func (t *tree) handleLoginPage(w http.ResponseWriter, r *http.Request) {
	next := localPath(r.URL.Query().Get("next"))
	if system.CanEdit(r) {
		http.Redirect(w, r, next, http.StatusFound)
		return
	}
	t.renderLogin(w, r, http.StatusOK, loginData{Next: next})
}

func (t *tree) handleLogin(w http.ResponseWriter, r *http.Request) {
	if !t.hasLogin() {
		t.renderLogin(w, r, http.StatusForbidden, loginData{})
		return
	}
	addr := t.clientAddr(r)
	d := loginData{Next: localPath(r.PostFormValue("next")), User: r.PostFormValue("user")}
	if ok, wait := t.limiter.Allowed(addr); !ok {
		w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
		d.Err = fmt.Sprintf("Too many failed attempts. Try again in %d minutes.", int(wait.Minutes())+1)
		t.renderLogin(w, r, http.StatusTooManyRequests, d)
		return
	}

	// One password check against the one configured hash, whatever name was
	// typed, so the time an attempt takes does not tell whether the name was
	// right.
	user, hash := t.loginPair()
	nameOK := subtle.ConstantTimeCompare([]byte(d.User), []byte(user)) == 1
	passOK := auth.Verify(r.PostFormValue("password"), hash)
	if !nameOK || !passOK {
		locked := t.limiter.Fail(addr)
		t.log.Warn("login failed", "addr", addr, "locked", locked)
		d.Err = "Wrong user name or password."
		t.renderLogin(w, r, http.StatusUnauthorized, d)
		return
	}

	t.limiter.Reset(addr)
	id, err := t.sessions.Create()
	if err != nil {
		t.log.Error("creating a session", "err", err)
		http.Error(w, "could not create a session", http.StatusInternalServerError)
		return
	}
	t.setSession(w, r, id, int(auth.SessionTTL.Seconds()))
	t.log.Info("logged in", "addr", addr)
	http.Redirect(w, r, d.Next, http.StatusSeeOther)
}

func (t *tree) handleLogout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie(sessionCookie); err == nil {
		t.sessions.Delete(c.Value)
	}
	t.setSession(w, r, "", -1)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

// setSession marks the cookie Secure only when the browser is on HTTPS, here
// or at a trusted proxy: on a plain-HTTP LAN install a Secure cookie would
// never be stored at all.
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, id string, maxAge int) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    id,
		Path:     "/",
		MaxAge:   maxAge,
		HttpOnly: true,
		Secure:   s.trusted.Scheme(r) == "https",
		SameSite: http.SameSiteLaxMode,
	})
}