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: 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. |
| 24 | const sessionCookie = "session" |
| 25 | |
| 26 | const 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. |
| 31 | const 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. |
| 36 | const ( |
| 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. |
| 43 | const 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. |
| 47 | func (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. |
| 58 | func (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 | |
| 64 | func (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 | |
| 77 | func (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 | |
| 89 | func (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 | |
| 108 | func 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. |
| 114 | func (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 | |
| 124 | func (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 | |
| 132 | func (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. |
| 167 | func (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 | |
| 177 | func (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 | |
| 191 | func 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. |
| 201 | func 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. |
| 217 | func 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. |
| 231 | func 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 |