auth.go
| 1 | package handler |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | const sessionCookie = "session" |
| 26 | |
| 27 | const 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. |
| 32 | const 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. |
| 38 | const ( |
| 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. |
| 45 | const 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. |
| 49 | func (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. |
| 60 | func (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 | |
| 66 | func (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 | |
| 79 | func (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 | |
| 91 | func (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. |
| 112 | func 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. |
| 119 | func (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 | |
| 129 | func (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 | |
| 137 | func (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. |
| 172 | func (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 | |
| 182 | func (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 | |
| 196 | func 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. |
| 206 | func 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. |
| 224 | func 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. |
| 241 | func 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 |