auth.go
⎇
Raw
1package handler
2
3import (
4 "crypto/hmac"
5 "crypto/rand"
6 "crypto/sha256"
7 "crypto/subtle"
8 "encoding/hex"
9 "fmt"
10 "log/slog"
11 "net/http"
12 "strconv"
13 "strings"
14 "time"
15
16 "golang.org/x/crypto/bcrypt"
17
18 "vidarchive/internal/service"
19)
20
21// Authentication is mandatory: nothing here has a disabled path, and the server
22// refuses to start without credentials (see checkCredentials in main). The
23// session is a signed cookie rather than server-side state; sessionKey explains
24// what signs it.
25const sessionCookie = "session"
26
27const sessionTTL = 30 * 24 * time.Hour
28
29// maxLoginCost bounds bcrypt's work factor. bcrypt itself only refuses a cost
30// outside 4..31, and the top of that range takes hours per attempt. Cost 15 is
31// roughly two seconds, the most a login can take and still fit in loginWait.
32const maxLoginCost = 15
33
34// maxConcurrentLogins caps how many password checks run at once. /login is
35// public and bcrypt is deliberately slow, so without this an unauthenticated
36// flood pins every core. loginWait bounds how long a request queues for a slot
37// rather than parking a goroutine indefinitely.
38const (
39 maxConcurrentLogins = 2
40 loginWait = 3 * time.Second
41)
42
43// maxLoginBody is generous for two form fields, and stops a large body from
44// being read into memory before the credentials are even looked at.
45const maxLoginBody = 4 << 10
46
47// sessionKey derives the cookie signing key. The stored secret is mixed in so
48// Logout can rotate it and make every cookie issued so far stop verifying.
49func (h *Handler) sessionKey() []byte {
50 h.sessionMu.RLock()
51 secret := h.sessionSecret
52 h.sessionMu.RUnlock()
53
54 sum := sha256.Sum256([]byte(h.cfg.Username + ":" + h.cfg.PasswordHash + ":" + secret))
55 return sum[:]
56}
57
58// signExpiry returns the MAC binding a session to its expiry time. Signing the
59// expiry is what stops a client from extending its own session.
60func (h *Handler) signExpiry(exp int64) string {
61 mac := hmac.New(sha256.New, h.sessionKey())
62 fmt.Fprintf(mac, "%d", exp)
63 return hex.EncodeToString(mac.Sum(nil))
64}
65
66func (h *Handler) issueSession(w http.ResponseWriter) {
67 exp := time.Now().Add(sessionTTL).Unix()
68 http.SetCookie(w, &http.Cookie{
69 Name: sessionCookie,
70 Value: fmt.Sprintf("%d|%s", exp, h.signExpiry(exp)),
71 Path: "/",
72 MaxAge: int(sessionTTL.Seconds()),
73 HttpOnly: true,
74 Secure: h.cfg.IsHTTPS(),
75 SameSite: http.SameSiteLaxMode,
76 })
77}
78
79func (h *Handler) clearSession(w http.ResponseWriter) {
80 http.SetCookie(w, &http.Cookie{
81 Name: sessionCookie,
82 Value: "",
83 Path: "/",
84 MaxAge: -1,
85 HttpOnly: true,
86 Secure: h.cfg.IsHTTPS(),
87 SameSite: http.SameSiteLaxMode,
88 })
89}
90
91func (h *Handler) hasSession(r *http.Request) bool {
92 c, err := r.Cookie(sessionCookie)
93 if err != nil {
94 return false
95 }
96 rawExp, mac, ok := strings.Cut(c.Value, "|")
97 if !ok {
98 return false
99 }
100 exp, err := strconv.ParseInt(rawExp, 10, 64)
101 if err != nil {
102 return false
103 }
104 if !hmac.Equal([]byte(mac), []byte(h.signExpiry(exp))) {
105 return false
106 }
107 return time.Now().Unix() < exp
108}
109
110// publicPath reports the routes reachable without a session: the login form
111// itself, the assets it needs to render, and the health check a monitor scrapes.
112func publicPath(path string) bool {
113 return path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/")
114}
115
116// RequireAuth guards every route. It is a single middleware rather than
117// per-route wrapping, so a new route is protected by default. Forgetting to
118// guard one is the failure mode that matters here.
119func (h *Handler) RequireAuth(next http.Handler) http.Handler {
120 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121 if publicPath(r.URL.Path) || h.hasSession(r) {
122 next.ServeHTTP(w, r)
123 return
124 }
125 http.Redirect(w, r, "/login", http.StatusSeeOther)
126 })
127}
128
129func (h *Handler) LoginForm(w http.ResponseWriter, r *http.Request) {
130 if h.hasSession(r) {
131 http.Redirect(w, r, "/", http.StatusSeeOther)
132 return
133 }
134 h.renderWithRequest(w, r, "login", PageData{Title: "Sign in", ActiveTab: "login"})
135}
136
137func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
138 r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
139 if err := r.ParseForm(); err != nil {
140 http.Error(w, "Invalid form", http.StatusBadRequest)
141 return
142 }
143
144 // Take a slot before hashing, so the work is bounded no matter how many
145 // requests arrive. Giving up after loginWait keeps a flood from queueing.
146 select {
147 case h.loginSem <- struct{}{}:
148 defer func() { <-h.loginSem }()
149 case <-time.After(loginWait):
150 flashError(w, "Too many sign-in attempts right now. Please try again.")
151 http.Redirect(w, r, "/login", http.StatusSeeOther)
152 return
153 }
154
155 // Verify the password even when the username is wrong, so a valid username
156 // can't be identified by how fast the request comes back.
157 userOK := subtle.ConstantTimeCompare([]byte(r.FormValue("username")), []byte(h.cfg.Username)) == 1
158 passOK := VerifyPassword(h.cfg.PasswordHash, r.FormValue("password"))
159 if !userOK || !passOK {
160 flashError(w, "Wrong username or password.")
161 http.Redirect(w, r, "/login", http.StatusSeeOther)
162 return
163 }
164
165 h.issueSession(w)
166 http.Redirect(w, r, "/", http.StatusSeeOther)
167}
168
169// Logout clears the browser's cookie and rotates the signing secret, so a copy
170// of that cookie taken beforehand stops working too. There is one account, so
171// invalidating every session is exactly the intent.
172func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
173 h.clearSession(w)
174 if err := h.rotateSessionSecret(); err != nil {
175 // This browser is signed out either way, since its cookie is gone. Only a
176 // copy taken elsewhere survives, which is worth a loud log.
177 slog.Error("failed to rotate the session secret; cookies issued earlier stay valid", "err", err)
178 }
179 http.Redirect(w, r, "/login", http.StatusSeeOther)
180}
181
182func (h *Handler) rotateSessionSecret() error {
183 secret, err := newSessionSecret()
184 if err != nil {
185 return err
186 }
187 if err := h.settingsSvc.SetSessionSecret(secret); err != nil {
188 return err
189 }
190 h.sessionMu.Lock()
191 h.sessionSecret = secret
192 h.sessionMu.Unlock()
193 return nil
194}
195
196func newSessionSecret() (string, error) {
197 var b [32]byte
198 if _, err := rand.Read(b[:]); err != nil {
199 return "", err
200 }
201 return hex.EncodeToString(b[:]), nil
202}
203
204// loadSessionSecret returns the stored signing secret, creating one on first
205// run. Persisting it is what lets sessions survive a restart.
206func loadSessionSecret(settingsSvc *service.SettingsService) (string, error) {
207 secret, err := settingsSvc.GetSessionSecret()
208 if err != nil {
209 return "", err
210 }
211 if secret != "" {
212 return secret, nil
213 }
214 if secret, err = newSessionSecret(); err != nil {
215 return "", err
216 }
217 return secret, settingsSvc.SetSessionSecret(secret)
218}
219
220// ValidatePasswordHash reports whether VIDARCHIVE_PASSWORD_HASH is a bcrypt
221// hash this server can actually use. Startup calls it, so a malformed or
222// absurdly expensive hash fails there instead of turning into a login that
223// mysteriously fails or never returns.
224func ValidatePasswordHash(encoded string) error {
225 cost, err := bcrypt.Cost([]byte(encoded))
226 if err != nil {
227 return fmt.Errorf("not a bcrypt hash: %w", err)
228 }
229 if cost > maxLoginCost {
230 return fmt.Errorf("bcrypt cost %d is above the usable maximum %d", cost, maxLoginCost)
231 }
232 return nil
233}
234
235// VerifyPassword reports whether password matches the stored bcrypt hash. The
236// comparison is constant time.
237//
238// The hash is re-validated first. Startup already did that, but this check is
239// what bounds the work: deriving against a cost-31 hash would hold a login slot
240// for hours, and reading the cost header is free by comparison.
241func VerifyPassword(encoded, password string) bool {
242 if ValidatePasswordHash(encoded) != nil {
243 return false
244 }
245 return bcrypt.CompareHashAndPassword([]byte(encoded), []byte(password)) == nil
246}
247