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