Files
infix/src/webui/internal/server/middleware.go
T
Joachim Wiberg 917eec3f25 webui: anonymous login, user menu, theme toggle, in-memory sessions
Anonymise the login page: generic "Login" title, lock icon instead
of product logo — makes the device harder to fingerprint.

Add a floating theme toggle button to the login page so users can
switch theme before logging in (cycles auto→light→dark).

Replace the four cluttered sidebar footer buttons (theme, download
config, reboot, logout) with an always-visible topbar user menu.
The dropdown shows the username, three theme options with a checkmark
on the active one, download config, reboot, and logout.

Refactor all handler page-data structs to embed PageData instead of
repeating the four base fields, and add Username (sourced from the
request context) so the topbar can display the logged-in user.

Polish pass on the login card: spacing, focus styles, button weights.

Replace the AES-encrypted-cookie session model with an opaque random
token backed by an in-memory map. The vendored design embedded the
user's username, password, CSRF, and feature flags into the cookie
and persisted the AES key under /var/lib/misc, making the keyfile a
passive credential-recovery oracle — anyone who could read it could
decrypt any captured cookie and recover the plaintext admin password.

Now: cookie is 32 bytes of crypto/rand, base64-url-encoded; sessions
live only in process memory; reboot/respawn/upgrade drops every
active session and the UI's 401 handler bounces the user to /login.
Sliding-window refresh on user activity, janitor goroutine sweeps
expired entries. --session-key flag and /var/lib/misc/webui-session.key
are gone.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-14 22:07:24 +02:00

169 lines
4.4 KiB
Go

// SPDX-License-Identifier: MIT
package server
import (
"net/http"
"net/url"
"strings"
"github.com/kernelkit/webui/internal/auth"
"github.com/kernelkit/webui/internal/handlers"
"github.com/kernelkit/webui/internal/restconf"
"github.com/kernelkit/webui/internal/security"
)
const cookieName = "session"
// authMiddleware checks the session cookie on every request, looks
// the token up in the in-memory store, and attaches the session's
// credentials to the context. Unauthenticated requests are
// redirected to /login (or get a 401 if the request comes from HTMX).
func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(cookieName)
if err != nil {
deny(w, r)
return
}
username, password, csrf, features, ok := store.Lookup(cookie.Value)
if !ok {
deny(w, r)
return
}
// Sliding window: extend the session on user-driven requests.
// Background polling endpoints are excluded so an idle user
// (just leaving the dashboard open in a tab) still hits the
// timeout.
if !isPollingPath(r.URL.Path) {
store.Refresh(cookie.Value)
}
ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{
Username: username,
Password: password,
})
ctx = security.WithToken(ctx, csrf)
ctx = handlers.ContextWithCapabilities(ctx, handlers.NewCapabilities(features))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func csrfMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := security.TokenFromContext(r.Context())
token = security.EnsureToken(w, r, token)
r = r.WithContext(security.WithToken(r.Context(), token))
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
next.ServeHTTP(w, r)
return
}
if !sameOrigin(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !validCSRF(r, token) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func sameOrigin(r *http.Request) bool {
host := r.Host
if xf := r.Header.Get("X-Forwarded-Host"); xf != "" {
parts := strings.Split(xf, ",")
host = strings.TrimSpace(parts[0])
}
if host == "" {
return false
}
origin := r.Header.Get("Origin")
if origin != "" {
u, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(u.Host, host)
}
ref := r.Header.Get("Referer")
if ref != "" {
u, err := url.Parse(ref)
if err != nil {
return false
}
return strings.EqualFold(u.Host, host)
}
return true
}
func validCSRF(r *http.Request, token string) bool {
if token == "" {
return false
}
if hdr := r.Header.Get("X-CSRF-Token"); hdr != "" {
return subtleConstantTimeEquals(hdr, token)
}
if err := r.ParseForm(); err != nil {
return false
}
return subtleConstantTimeEquals(r.FormValue("csrf"), token)
}
func subtleConstantTimeEquals(a, b string) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")
if security.IsSecureRequest(r) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func isPublicPath(path string) bool {
return path == "/login" || strings.HasPrefix(path, "/assets/")
}
func isPollingPath(path string) bool {
return path == "/device-status" || strings.HasSuffix(path, "/counters")
}
func deny(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}