538a4b6603
Sessions move from an in-memory map (absolute 12h TTL) to a `sessions` table (migration 0002), storing only the SHA-256 of the token. Expiry is now a sliding idle window (PANEL_SESSION_IDLE_DAYS, default 7, no absolute cap), extended at most once an hour and never by the monitoring screens' background polling (GET + HX-Request), so a forgotten open tab doesn't keep a session alive indefinitely. A login now survives a container restart or redeploy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
194 lines
6.5 KiB
Go
194 lines
6.5 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"codeberg.org/mix/selfpost/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// The panel session cookie's two possible names. In the production shape —
|
|
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
|
|
// what the cookie's attributes merely promise into something the browser
|
|
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
|
|
// attribute, so no other host may set a cookie by this name (phase 14.B).
|
|
// The prefix is only valid on a Secure cookie, so a development instance on
|
|
// plain HTTP has to keep the bare name: with the prefix the browser would
|
|
// discard the Set-Cookie outright and logging in would silently never stick.
|
|
const (
|
|
sessionCookieBase = "selfpost_session"
|
|
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
|
)
|
|
|
|
// sessionCookie is the session cookie's name for this deployment.
|
|
func (s *Server) sessionCookie() string {
|
|
if s.cfg.CookieSecure {
|
|
return sessionCookiePrefixed
|
|
}
|
|
return sessionCookieBase
|
|
}
|
|
|
|
// sessionToken returns the session token the request carries, if exactly one
|
|
// cookie of that name is present.
|
|
//
|
|
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
|
|
// the first match. Two cookies with the same name mean somebody other than
|
|
// this panel set one of them — a host on the same registrable domain can,
|
|
// with Domain=example.com, and the browser will then send both — and RFC 6265
|
|
// makes the older one come first, so "the first match" is precisely the
|
|
// attacker's. The value cannot be forged into a valid session, so the effect
|
|
// is denial of service, not compromise; refusing the request and saying so in
|
|
// the log is what makes it diagnosable instead of an endless login loop. The
|
|
// __Host- prefix prevents this outright, but only where it applies — this
|
|
// check also covers the plain-HTTP development shape (phase 14.B).
|
|
func (s *Server) sessionToken(r *http.Request) (string, bool) {
|
|
name := s.sessionCookie()
|
|
var token string
|
|
var n int
|
|
for _, c := range r.Cookies() {
|
|
if c.Name == name {
|
|
n++
|
|
token = c.Value
|
|
}
|
|
}
|
|
switch n {
|
|
case 0:
|
|
return "", false
|
|
case 1:
|
|
return token, true
|
|
default:
|
|
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
|
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
|
r.Method, r.URL.Path, n, name)
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// clearSessionCookies expires the session cookie under both names, so an
|
|
// upgrade that switches to the __Host- prefix does not leave the old cookie
|
|
// sitting in the browser until it is closed.
|
|
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
|
|
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
// The prefixed name is only accepted at all when Secure is set,
|
|
// including on the expiring copy.
|
|
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleLogin serves the login form (GET) and authenticates (POST). Until an
|
|
// administrator exists there is nobody to log in, so it points at setup.
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
exists, err := s.store.AdminExists()
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !exists {
|
|
// No admin yet: login is meaningless. Send a clear message rather than
|
|
// a failing form.
|
|
s.render(w, http.StatusOK, "login", map[string]any{
|
|
"Title": "SelfPost — Sign in",
|
|
"SetupHint": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderLogin(w, http.StatusOK, "")
|
|
case http.MethodPost:
|
|
s.submitLogin(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
|
s.render(w, status, "login", map[string]any{
|
|
"Title": "SelfPost — Sign in",
|
|
"Error": formErr,
|
|
})
|
|
}
|
|
|
|
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|
// Brute-force throttle by client IP (spec 7.6.5).
|
|
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
password := r.PostFormValue("password")
|
|
|
|
admin, err := s.store.GetAdmin()
|
|
if err != nil {
|
|
if !errors.Is(err, store.ErrNoAdmin) {
|
|
logf("panel: login: get admin failed: %v", err)
|
|
}
|
|
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
return
|
|
}
|
|
|
|
// Always run bcrypt so timing does not distinguish "wrong user" from
|
|
// "wrong password", and compare the username too.
|
|
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
|
if username != admin.Username || pwErr != nil {
|
|
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
return
|
|
}
|
|
|
|
token := s.sessions.Create(admin.Username)
|
|
s.setSessionCookie(w, token)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
|
|
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
|
|
// whatever the database row was just set to — at login, and again whenever
|
|
// requireAuth extends an active session.
|
|
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.sessionCookie(),
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: s.sessions.MaxAge(),
|
|
HttpOnly: true,
|
|
Secure: s.cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// handleLogout destroys the session and clears the cookie.
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
// Destroy every token presented under the session cookie's name: if a
|
|
// shadowing duplicate is present (see sessionToken) one of them is the
|
|
// real session, and a value that names no session is simply not found.
|
|
name := s.sessionCookie()
|
|
for _, c := range r.Cookies() {
|
|
if c.Name == name {
|
|
s.sessions.Destroy(c.Value)
|
|
}
|
|
}
|
|
s.clearSessionCookies(w)
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|