Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Lay out view, auth, validate, and handlers under internal/web while keeping the cmd/panel API unchanged; update roadmap and changelog for web-split closure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Package auth implements the panel's login sessions, one-time setup flow,
|
||||
// and authentication middleware.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// Config holds auth-specific panel configuration.
|
||||
type Config struct {
|
||||
CookieSecure bool
|
||||
Hostname string
|
||||
SessionIdleDays int
|
||||
TrustedProxyCIDRs []*net.IPNet
|
||||
}
|
||||
|
||||
// Module handles login, logout, setup, and session middleware.
|
||||
type Module struct {
|
||||
store *store.Store
|
||||
cfg Config
|
||||
view *view.Engine
|
||||
sessions *sessionStore
|
||||
setup *setupManager
|
||||
|
||||
loginLimiter *rateLimiter
|
||||
setupLimiter *rateLimiter
|
||||
|
||||
trustedProxies []*net.IPNet
|
||||
}
|
||||
|
||||
// New builds the auth module. setupTokenPath is where the current setup token
|
||||
// is mirrored on disk (security.md).
|
||||
func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module {
|
||||
idleDays := cfg.SessionIdleDays
|
||||
if idleDays <= 0 {
|
||||
idleDays = 7
|
||||
}
|
||||
m := &Module{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
view: v,
|
||||
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
||||
setupLimiter: newRateLimiter(10, time.Minute),
|
||||
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||
}
|
||||
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||
return m
|
||||
}
|
||||
|
||||
// Bootstrap runs once at startup. If setup is not complete it mints and
|
||||
// announces the first setup token (security.md).
|
||||
func (m *Module) Bootstrap() error {
|
||||
return m.setup.bootstrap()
|
||||
}
|
||||
|
||||
// AllowLoginAttempt reports whether a login or account-password change attempt
|
||||
// from r is within the rate limit (security.md).
|
||||
func (m *Module) AllowLoginAttempt(r *http.Request) bool {
|
||||
return m.loginLimiter.Allow(clientIP(r, m.trustedProxies))
|
||||
}
|
||||
|
||||
// SessionToken returns the session token the request carries, if exactly one
|
||||
// cookie of that name is present.
|
||||
func (m *Module) SessionToken(r *http.Request) (string, bool) {
|
||||
return m.sessionToken(r)
|
||||
}
|
||||
|
||||
// RenameSession updates the username carried by a session.
|
||||
func (m *Module) RenameSession(token, username string) {
|
||||
m.sessions.Rename(token, username)
|
||||
}
|
||||
|
||||
// DestroyOtherSessions invalidates every session except keep.
|
||||
func (m *Module) DestroyOtherSessions(keep string) {
|
||||
m.sessions.DestroyOthers(keep)
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
// clientIP extracts the peer IP for rate-limiting. By default it is the
|
||||
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
|
||||
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
||||
// that is the address the trusted proxy itself appended, so a client can't
|
||||
// forge it by sending its own XFF header.
|
||||
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
if len(trustedProxies) > 0 {
|
||||
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
func newTestSessionStore(t *testing.T) *sessionStore {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return newSessionStore(st, 7*24*time.Hour)
|
||||
}
|
||||
|
||||
func mustView(t *testing.T) *view.Engine {
|
||||
t.Helper()
|
||||
v, err := view.New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("view: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func testModule(t *testing.T, cookieSecure bool) *Module {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return New(st, Config{CookieSecure: cookieSecure}, mustView(t), "")
|
||||
}
|
||||
|
||||
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
||||
secure := testModule(t, true)
|
||||
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
||||
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
||||
}
|
||||
plain := testModule(t, false)
|
||||
if got := plain.sessionCookie(); got != "selfpost_session" {
|
||||
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
||||
|
||||
if token, ok := m.sessionToken(r); ok {
|
||||
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
||||
m := testModule(t, true)
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
||||
|
||||
token, ok := m.sessionToken(r)
|
||||
if !ok || token != "the-real-session" {
|
||||
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
||||
m := testModule(t, true)
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
||||
|
||||
if _, ok := m.sessionToken(r); ok {
|
||||
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
token := m.sessions.Create("admin")
|
||||
|
||||
reached := false
|
||||
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
|
||||
if reached {
|
||||
t.Fatal("the handler ran even though the session cookie was shadowed")
|
||||
}
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
||||
m := testModule(t, true)
|
||||
token := m.sessions.Create("admin")
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
||||
r.Host = "panel.example.com"
|
||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
m.HandleLogout(rec, r)
|
||||
|
||||
if _, ok := m.sessions.Lookup(token); ok {
|
||||
t.Error("the session survived sign-out")
|
||||
}
|
||||
set := rec.Header().Values("Set-Cookie")
|
||||
for _, name := range []string{"selfpost_session=", "__Host-selfpost_session="} {
|
||||
var found bool
|
||||
for _, c := range set {
|
||||
if strings.HasPrefix(c, name) && strings.Contains(c, "Max-Age=0") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("sign-out does not expire a cookie named %q: %v", strings.TrimSuffix(name, "="), set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRename(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
token := s.Create("admin")
|
||||
|
||||
s.Rename(token, "operator")
|
||||
|
||||
name, ok := s.Lookup(token)
|
||||
if !ok {
|
||||
t.Fatal("session lost after rename")
|
||||
}
|
||||
if name != "operator" {
|
||||
t.Fatalf("session username = %q, want %q", name, "operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDestroyOthers(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
keep := s.Create("admin")
|
||||
other := s.Create("admin")
|
||||
|
||||
s.DestroyOthers(keep)
|
||||
|
||||
if _, ok := s.Lookup(keep); !ok {
|
||||
t.Fatal("current session was destroyed")
|
||||
}
|
||||
if _, ok := s.Lookup(other); ok {
|
||||
t.Fatal("other session survived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLookupRejectsExpired(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
s.idle = -time.Minute
|
||||
token := s.Create("admin")
|
||||
|
||||
if _, ok := s.Lookup(token); ok {
|
||||
t.Fatal("expired session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTouchThrottled(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
token := s.Create("admin")
|
||||
|
||||
if s.Touch(token) {
|
||||
t.Fatal("touch renewed a session created moments ago")
|
||||
}
|
||||
|
||||
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
||||
t.Fatalf("renew session: %v", err)
|
||||
}
|
||||
if !s.Touch(token) {
|
||||
t.Fatal("touch did not renew a session past the throttle window")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieBase = "selfpost_session"
|
||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||
)
|
||||
|
||||
func (m *Module) sessionCookie() string {
|
||||
if m.cfg.CookieSecure {
|
||||
return sessionCookiePrefixed
|
||||
}
|
||||
return sessionCookieBase
|
||||
}
|
||||
|
||||
func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
||||
name := m.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
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
|
||||
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogin serves the login form (GET) and authenticates (POST).
|
||||
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
exists, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
m.view.Render(w, http.StatusOK, "login", map[string]any{
|
||||
"Title": "SelfPost — Sign in",
|
||||
"Active": "login",
|
||||
"SetupHint": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
m.renderLogin(w, http.StatusOK, "")
|
||||
case http.MethodPost:
|
||||
m.submitLogin(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
||||
m.view.Render(w, status, "login", map[string]any{
|
||||
"Title": "SelfPost — Sign in",
|
||||
"Active": "login",
|
||||
"Error": formErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
|
||||
admin, err := m.store.GetAdmin()
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNoAdmin) {
|
||||
logf("panel: login: get admin failed: %v", err)
|
||||
}
|
||||
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||
return
|
||||
}
|
||||
|
||||
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
||||
if username != admin.Username || pwErr != nil {
|
||||
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||
return
|
||||
}
|
||||
|
||||
token := m.sessions.Create(admin.Username)
|
||||
m.setSessionCookie(w, token)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: m.sessionCookie(),
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: m.sessions.MaxAge(),
|
||||
HttpOnly: true,
|
||||
Secure: m.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleLogout destroys the session and clears the cookie.
|
||||
func (m *Module) 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
|
||||
}
|
||||
name := m.sessionCookie()
|
||||
for _, c := range r.Cookies() {
|
||||
if c.Name == name {
|
||||
m.sessions.Destroy(c.Value)
|
||||
}
|
||||
}
|
||||
m.clearSessionCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleSetup serves the one-time administrator creation flow at
|
||||
// /setup/<token> (security.md).
|
||||
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
||||
if token == "" || strings.Contains(token, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !m.setup.validate(token) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
m.renderSetupForm(w, http.StatusOK, token, "")
|
||||
case http.MethodPost:
|
||||
m.submitSetup(w, r, token)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
||||
m.view.Render(w, status, "setup", map[string]any{
|
||||
"Title": "SelfPost — Create administrator",
|
||||
"Active": "setup",
|
||||
"Token": token,
|
||||
"Error": formErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
confirm := r.PostFormValue("password_confirm")
|
||||
|
||||
if err := validate.Username(username); err != nil {
|
||||
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: setup: hashing password failed: %v", err)
|
||||
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.store.CreateAdmin(username, string(hash)); err != nil {
|
||||
if exists, _ := m.store.AdminExists(); exists {
|
||||
m.setup.complete()
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
logf("panel: setup: create admin failed: %v", err)
|
||||
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
m.setup.complete()
|
||||
logf("panel: administrator %q created; setup link is now disabled", username)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const usernameKey ctxKey = 0
|
||||
|
||||
// RequireAuth wraps a handler so only requests with a valid session cookie
|
||||
// reach it; everyone else is redirected to the login page. The authenticated
|
||||
// username is stashed in the request context for downstream handlers.
|
||||
func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := m.sessionToken(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
username, ok := m.sessions.Lookup(token)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if isSessionActivity(r) && m.sessions.Touch(token) {
|
||||
m.setSessionCookie(w, token)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func isSessionActivity(r *http.Request) bool {
|
||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
||||
}
|
||||
|
||||
// CurrentUser returns the authenticated username from the request context.
|
||||
func CurrentUser(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||
// setup and login routes (security.md). Keys are client IPs.
|
||||
type rateLimiter struct {
|
||||
max int
|
||||
window time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
count int
|
||||
windowEnds time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
max: max,
|
||||
window: window,
|
||||
buckets: make(map[string]*rlBucket),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rateLimiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
b := r.buckets[key]
|
||||
if b == nil || now.After(b.windowEnds) {
|
||||
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
||||
r.sweep(now)
|
||||
return true
|
||||
}
|
||||
if b.count >= r.max {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *rateLimiter) sweep(now time.Time) {
|
||||
for k, b := range r.buckets {
|
||||
if now.After(b.windowEnds) {
|
||||
delete(r.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// renewThreshold bounds how often an active session's expiry is written back
|
||||
// to the database. Renewing on every request would mean a write (and a new
|
||||
// Set-Cookie) per click; renewing at most once an hour keeps that cost low
|
||||
// while still keeping a busy admin's session alive indefinitely (plan B.1).
|
||||
const renewThreshold = time.Hour
|
||||
|
||||
// sessionStore persists login sessions in the database (plan B.1): a login
|
||||
// survives a container restart or redeploy. Only the SHA-256 of the token is
|
||||
// stored, never the token itself (security.md's crypto-random bearer token), so
|
||||
// a stolen database file or backup archive cannot be replayed as a session —
|
||||
// it only extends the login of whichever browser still holds the original
|
||||
// cookie.
|
||||
type sessionStore struct {
|
||||
store *store.Store
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
||||
return &sessionStore{store: st, idle: idle}
|
||||
}
|
||||
|
||||
// MaxAge is the session cookie's Max-Age in seconds, kept equal to the
|
||||
// sliding idle window so the browser drops the cookie no later than the
|
||||
// server would have expired it anyway.
|
||||
func (s *sessionStore) MaxAge() int {
|
||||
return int(s.idle.Seconds())
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Create issues a new session for username and returns its token.
|
||||
func (s *sessionStore) Create(username string) string {
|
||||
token := randomToken(32)
|
||||
now := time.Now()
|
||||
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
||||
logf("panel: session: create failed: %v", err)
|
||||
}
|
||||
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
||||
logf("panel: session: prune expired failed: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// Lookup returns the session username for a token if it exists and is
|
||||
// unexpired.
|
||||
func (s *sessionStore) Lookup(token string) (string, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
hash := hashToken(token)
|
||||
row, found, err := s.store.LookupSession(hash)
|
||||
if err != nil {
|
||||
logf("panel: session: lookup failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
if err := s.store.DeleteSession(hash); err != nil {
|
||||
logf("panel: session: delete expired failed: %v", err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return row.Username, true
|
||||
}
|
||||
|
||||
// Touch extends a session's sliding expiry if it has been at least
|
||||
// renewThreshold since the last extension, and reports whether it did so.
|
||||
func (s *sessionStore) Touch(token string) bool {
|
||||
hash := hashToken(token)
|
||||
row, found, err := s.store.LookupSession(hash)
|
||||
if err != nil {
|
||||
logf("panel: session: touch lookup failed: %v", err)
|
||||
return false
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
||||
now := time.Now()
|
||||
if now.Sub(lastRenewal) < renewThreshold {
|
||||
return false
|
||||
}
|
||||
if err := s.store.RenewSession(hash, now.Add(s.idle)); err != nil {
|
||||
logf("panel: session: renew failed: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Rename updates the username carried by a session, keeping its expiry.
|
||||
func (s *sessionStore) Rename(token, username string) {
|
||||
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
||||
logf("panel: session: rename failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DestroyOthers invalidates every session except keep.
|
||||
func (s *sessionStore) DestroyOthers(keep string) {
|
||||
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
||||
logf("panel: session: destroy others failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy invalidates a session token (logout).
|
||||
func (s *sessionStore) Destroy(token string) {
|
||||
if err := s.store.DeleteSession(hashToken(token)); err != nil {
|
||||
logf("panel: session: destroy failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// setupTokenTTL is the lifetime of a setup token (security.md). After it
|
||||
// elapses the token is regenerated and re-announced on the next /setup hit.
|
||||
const setupTokenTTL = 10 * time.Minute
|
||||
|
||||
// setupManager owns the one-time administrator setup token. The token itself is
|
||||
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
|
||||
// persistent "setup complete" fact is the presence of the admin row in the
|
||||
// store, so once that exists the token is gone for good (security.md).
|
||||
type setupManager struct {
|
||||
store *store.Store
|
||||
hostname string
|
||||
tokenPath string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager {
|
||||
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
||||
}
|
||||
|
||||
func (m *setupManager) bootstrap() error {
|
||||
done, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
m.clearTokenFile()
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.regenerateLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *setupManager) activeToken() (string, bool) {
|
||||
done, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
logf("panel: setup: admin check failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if done {
|
||||
return "", false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.token == "" || time.Now().After(m.expiresAt) {
|
||||
m.regenerateLocked()
|
||||
}
|
||||
return m.token, true
|
||||
}
|
||||
|
||||
func (m *setupManager) validate(provided string) bool {
|
||||
token, ok := m.activeToken()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func (m *setupManager) complete() {
|
||||
m.mu.Lock()
|
||||
m.token = ""
|
||||
m.expiresAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
m.clearTokenFile()
|
||||
}
|
||||
|
||||
func (m *setupManager) regenerateLocked() {
|
||||
m.token = randomToken(16)
|
||||
m.expiresAt = time.Now().Add(setupTokenTTL)
|
||||
m.announce(m.token)
|
||||
}
|
||||
|
||||
func (m *setupManager) announce(token string) {
|
||||
url := m.setupURL(token)
|
||||
logf("panel: ==================================================================")
|
||||
logf("panel: SelfPost first-run setup — open this one-time link within %s:", setupTokenTTL)
|
||||
logf("panel: %s", url)
|
||||
logf("panel: (also written to %s)", m.tokenPath)
|
||||
logf("panel: ==================================================================")
|
||||
|
||||
if m.tokenPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
||||
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *setupManager) setupURL(token string) string {
|
||||
host := m.hostname
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return fmt.Sprintf("https://%s/setup/%s", host, token)
|
||||
}
|
||||
|
||||
func (m *setupManager) clearTokenFile() {
|
||||
if m.tokenPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(m.tokenPath); err != nil && !os.IsNotExist(err) {
|
||||
logf("panel: setup: could not remove token file %s: %v", m.tokenPath, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy
|
||||
// drawn from crypto/rand. Setup and session tokens both use this; the setup
|
||||
// token needs >=128 bits (security.md), so callers pass nBytes >= 16.
|
||||
//
|
||||
// It panics if the system RNG fails: that is unrecoverable and must never be
|
||||
// papered over with a weak fallback for a security token.
|
||||
func randomToken(nBytes int) string {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user