b82c10acf4
Part of the same security-hardening pass as the body-size/header commit. loginLimiter was an in-memory, per-process sync.Mutex+map -- fine for one replica, but charts/terdut-server/values.yaml has set replicaCount: 2 in production since v0.37.0. Each pod counted only its own traffic, so every limit it guarded (failed logins, sign-ups, OIDC/device-login starts) was effectively twice as generous as the constants say, not just in theory. loginLimiter now stores its counters in a new rate_limit_counters table (migration 016) instead of a map; blocked/fail/clear take a context and query/upsert/delete a row keyed by the same strings callers already used (username, client address, "signup:"+address, ...). Semantics are unchanged -- a fixed window that resets rather than slides -- so no call site's behavior changes, only where the count lives. Added purgeRateLimits to the sweeper, alongside purgeSessions/purgeAckTokens, so expired windows don't accumulate. New internal (package api) tests in rate_limiter_test.go cover the basic behavior plus the regression this exists to fix: two loginLimiter values sharing one database, standing in for two replicas, now see one combined count instead of each keeping their own. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
283 lines
9.3 KiB
Go
283 lines
9.3 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"log"
|
|
"math/big"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The device login flow lets a client that cannot open a browser sign in: it
|
|
// shows a code, the person approves it in a browser they are signed in to, and
|
|
// the client is handed an ordinary session. See migration 012.
|
|
|
|
const (
|
|
// deviceTTL is how long a person has to get from the terminal's prompt to an
|
|
// approval.
|
|
deviceTTL = 10 * time.Minute
|
|
|
|
// deviceInterval is how often the client is told to poll. The server holds it
|
|
// to that, with a second of slack for clocks and scheduling.
|
|
deviceInterval = 5 * time.Second
|
|
|
|
// deviceStartMaxPerAddr bounds unauthenticated device logins started per
|
|
// address, since each writes a row.
|
|
deviceStartMaxPerAddr = 30
|
|
|
|
// userCodeAlphabet has no vowels, so a code cannot spell a word, and none of
|
|
// the characters that read alike (0/O, 1/I/L).
|
|
userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789"
|
|
userCodeLen = 8
|
|
)
|
|
|
|
// newUserCode returns a code for a person to read, as XXXX-XXXX.
|
|
func newUserCode() (string, error) {
|
|
max := big.NewInt(int64(len(userCodeAlphabet)))
|
|
b := make([]byte, userCodeLen)
|
|
for i := range b {
|
|
n, err := rand.Int(rand.Reader, max)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b[i] = userCodeAlphabet[n.Int64()]
|
|
}
|
|
return string(b[:4]) + "-" + string(b[4:]), nil
|
|
}
|
|
|
|
// normalizeUserCode reduces whatever a person typed or pasted to the stored
|
|
// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for
|
|
// anything that cannot be a code.
|
|
func normalizeUserCode(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToUpper(s) {
|
|
if strings.ContainsRune(userCodeAlphabet, r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
code := b.String()
|
|
if len(code) != userCodeLen {
|
|
return ""
|
|
}
|
|
return code[:4] + "-" + code[4:]
|
|
}
|
|
|
|
// handleDeviceStart begins a device login: it returns the device code the
|
|
// client polls with, and the user code and URL the person is shown.
|
|
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
addrKey := "device:" + clientAddr(r)
|
|
if limiter.blocked(r.Context(), addrKey, deviceStartMaxPerAddr) {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
|
return
|
|
}
|
|
limiter.fail(r.Context(), addrKey)
|
|
|
|
deviceCode, deviceHash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix())
|
|
|
|
// A collision on the user code is one in 27^8; retrying a few times makes
|
|
// it a non-event rather than a 500.
|
|
var userCode string
|
|
for range 5 {
|
|
userCode, err = newUserCode()
|
|
if err != nil {
|
|
break
|
|
}
|
|
_, err = db.ExecContext(r.Context(), `
|
|
INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`,
|
|
deviceHash, userCode, now.Add(deviceTTL).Unix())
|
|
if err == nil || !isUniqueViolation(err) {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Printf("device login: start: %v", err)
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
respond(w, http.StatusOK, map[string]any{
|
|
"device_code": deviceCode,
|
|
"user_code": userCode,
|
|
// The code is in the URL so nobody has to type it; it is shown anyway,
|
|
// for the person to check against the terminal before approving.
|
|
"verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode),
|
|
"interval": int(deviceInterval.Seconds()),
|
|
"expires_in": int(deviceTTL.Seconds()),
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleDeviceDecision approves or denies a pending device login on behalf of
|
|
// the signed-in caller.
|
|
//
|
|
// It takes a session, not an API key. Approving hands a terminal the caller's
|
|
// identity, and the approval must come from a browser the person is looking at:
|
|
// the page shows the code and asks. A script with a key has no business
|
|
// approving one, and the check keeps it from being a way to mint sessions out of
|
|
// keys.
|
|
func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
|
|
status := "denied"
|
|
if approve {
|
|
status = "approved"
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, viaSession := sessionFromContext(r.Context()); !viaSession {
|
|
respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device"))
|
|
return
|
|
}
|
|
var req struct {
|
|
UserCode string `json:"user_code"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
code := normalizeUserCode(req.UserCode)
|
|
if code == "" {
|
|
respond(w, http.StatusBadRequest, errResp("that is not a sign-in code"))
|
|
return
|
|
}
|
|
|
|
caller, _ := userFromContext(r.Context())
|
|
// Only a pending login can be decided, and only once: an approval cannot
|
|
// be overwritten, so a second browser cannot take a login over.
|
|
res, err := db.ExecContext(r.Context(), `
|
|
UPDATE device_logins SET status = $1, user_id = $2
|
|
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
|
|
status, caller.ID, code, time.Now().Unix())
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleDeviceToken is what the client polls. Pending answers 202; an approval
|
|
// answers 200 with the session cookie, once; anything else is 410.
|
|
func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc {
|
|
gone := func(w http.ResponseWriter, why string) {
|
|
respond(w, http.StatusGone, map[string]string{"error": why})
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DeviceCode string `json:"device_code"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" {
|
|
respond(w, http.StatusBadRequest, errResp("device_code is required"))
|
|
return
|
|
}
|
|
hash := hashToken(req.DeviceCode)
|
|
now := time.Now()
|
|
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
var status string
|
|
var userID sql.NullInt64
|
|
var expires, lastPolled int64
|
|
err = tx.QueryRowContext(r.Context(), `
|
|
SELECT status, user_id, expires_at, last_polled_at FROM device_logins
|
|
WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled)
|
|
if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) {
|
|
gone(w, "expired")
|
|
return
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
switch status {
|
|
case "denied":
|
|
tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash)
|
|
tx.Commit() //nolint:errcheck
|
|
gone(w, "denied")
|
|
return
|
|
|
|
case "pending":
|
|
// Held to the interval it was given, less a second of slack.
|
|
if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds())))
|
|
respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"})
|
|
return
|
|
}
|
|
if _, err := tx.ExecContext(r.Context(),
|
|
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
|
|
return
|
|
}
|
|
|
|
// Approved. Single use: the row goes before the session is made, so two
|
|
// racing polls cannot both be given one.
|
|
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
var disabled, sso bool
|
|
if err := tx.QueryRowContext(r.Context(), `
|
|
SELECT disabled_at IS NOT NULL,
|
|
EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1)
|
|
FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil {
|
|
gone(w, "denied")
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if disabled {
|
|
gone(w, "denied")
|
|
return
|
|
}
|
|
|
|
// A session for somebody who signs in through the provider carries the
|
|
// same ceiling as their browser's would, so the terminal is not a way
|
|
// round it. Password users have none.
|
|
var maxAge time.Duration
|
|
if sso {
|
|
maxAge = ssoMaxAge
|
|
}
|
|
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
|
|
log.Printf("device login: start session: %v", err)
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
user, err := fetchUser(r.Context(), db, userID.Int64)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
|
|
}
|
|
}
|