internal/server/auth.go
Rama main · réplica de lectura del repositorio original.
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,
})
}