package api import ( "context" "database/sql" "errors" "log" "net" "net/http" "strconv" "strings" "sync" "time" "github.com/go-chi/chi/v5" "golang.org/x/crypto/bcrypt" ) const ( // sessionCookie carries a web UI session. It is HttpOnly, so page script // never sees the token; the page learns who it is from GET /api/me. sessionCookie = "terdut_session" // sessionTTL is how long a session lives without being used. It slides, so // a phone that opens the UI now and then stays signed in indefinitely. sessionTTL = 30 * 24 * time.Hour // sessionTouchEvery bounds how often a request may slide the expiry. sessionTouchEvery = time.Hour minPasswordLen = 10 // maxPasswordLen is bcrypt's limit; it rejects longer input outright. maxPasswordLen = 72 loginWindow = 15 * time.Minute loginMaxPerUser = 10 loginMaxPerAddr = 30 passwordHashCost = bcrypt.DefaultCost ) // dummyHash is compared against when the username is unknown or has no // password, so a failed login takes as long whichever way it failed. var dummyHash = sync.OnceValue(func() []byte { h, _ := bcrypt.GenerateFromPassword([]byte("terdut-dummy-password"), passwordHashCost) return h }) // loginLimiter counts failed logins in a fixed window, per username and per // client address. The username limit is what stops guessing one account; the // address limit is looser because every user behind the same gateway or NAT // shares it. type loginLimiter struct { mu sync.Mutex failures map[string]*loginWindowCount } type loginWindowCount struct { start time.Time n int } func newLoginLimiter() *loginLimiter { return &loginLimiter{failures: map[string]*loginWindowCount{}} } func (l *loginLimiter) blocked(key string, max int) bool { l.mu.Lock() defer l.mu.Unlock() c, ok := l.failures[key] if !ok || time.Since(c.start) > loginWindow { return false } return c.n >= max } func (l *loginLimiter) fail(keys ...string) { l.mu.Lock() defer l.mu.Unlock() now := time.Now() for k, c := range l.failures { if now.Sub(c.start) > loginWindow { delete(l.failures, k) } } for _, key := range keys { c, ok := l.failures[key] if !ok { c = &loginWindowCount{start: now} l.failures[key] = c } c.n++ } } func (l *loginLimiter) clear(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.failures, key) } // clientAddr is the address a login is counted against. Behind the gateway // RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used // when present. It can be forged, but only to dodge the address limit; the // per-username limit does not depend on it. func clientAddr(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { first, _, _ := strings.Cut(xff, ",") return strings.TrimSpace(first) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // cookieSecure decides the cookie's Secure flag. TLS terminates at the gateway, // so the server usually sees plain HTTP; the public URL is what says whether // browsers reach it over HTTPS. func cookieSecure(publicURL string, r *http.Request) bool { return strings.HasPrefix(publicURL, "https://") || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" } // validatePassword returns a message for the client, or "" when acceptable. func validatePassword(pw string) string { switch { case len(pw) < minPasswordLen: return "password must be at least " + strconv.Itoa(minPasswordLen) + " characters" case len(pw) > maxPasswordLen: return "password must be at most " + strconv.Itoa(maxPasswordLen) + " bytes" } return "" } func hashPassword(pw string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(pw), passwordHashCost) return string(h), err } // startSession mints a session and sets the cookie. Shared by login and // sign-up: somebody who has just chosen a password is signed in, rather than // being sent to a form to type the same credential again. func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error { raw, tokenHash, err := randomToken() if err != nil { return err } now := time.Now() if _, err := db.ExecContext(r.Context(), ` INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent) VALUES ($1, $2, $3, $4, $5, $6)`, tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: raw, Path: "/", MaxAge: int(sessionTTL.Seconds()), HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode, }) return nil } // handleLogin exchanges a username and password for a session cookie. func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Password string `json:"password"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } username := strings.TrimSpace(req.Username) userKey := "user:" + strings.ToLower(username) addrKey := "addr:" + clientAddr(r) if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) { w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later")) return } var userID int64 var hash sql.NullString err := db.QueryRowContext(r.Context(), "SELECT id, password_hash FROM users WHERE username = $1", username, ).Scan(&userID, &hash) if err != nil && !errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusInternalServerError, errResp("internal error")) return } stored := dummyHash() if hash.Valid { stored = []byte(hash.String) } match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil if !match || !hash.Valid { limiter.fail(userKey, addrKey) respond(w, http.StatusUnauthorized, errResp("invalid username or password")) return } limiter.clear(userKey) if err := startSession(w, r, db, userID, publicURL); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } user, err := fetchUser(r.Context(), db, userID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusOK, meResponse{User: user, HasPassword: true}) } } // handleLogout ends the browser's session. It sits outside AuthMiddleware so // that a browser holding an already-expired cookie can still clear it. func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc { crossOrigin := http.NewCrossOriginProtection() return func(w http.ResponseWriter, r *http.Request) { if err := crossOrigin.Check(r); err != nil { respond(w, http.StatusForbidden, errResp("cross-origin request rejected")) return } if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value)) } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode, }) w.WriteHeader(http.StatusNoContent) } } type meResponse struct { User any `json:"user"` HasPassword bool `json:"has_password"` // OnboardingDismissed is whether this person has put the first-run // checklist away. Per user rather than per browser: somebody who finishes // setting up on a laptop should not be nagged again on their phone. OnboardingDismissed bool `json:"onboarding_dismissed"` } // handleMe says who the caller is. The web UI calls it on load to decide // between the login form and the app, since it cannot read its own cookie. func handleMe(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { caller, _ := userFromContext(r.Context()) user, err := fetchUser(r.Context(), db, caller.ID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } var hash sql.NullString var dismissed *int64 db.QueryRowContext(r.Context(), "SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1", caller.ID).Scan(&hash, &dismissed) respond(w, http.StatusOK, meResponse{ User: user, HasPassword: hash.Valid, OnboardingDismissed: dismissed != nil, }) } } // handleSetPassword sets a user's web UI password. // // Changing your own password takes the current one, when there is one, so an // unattended signed-in browser cannot be used to take the account over. Setting // somebody else's is how an admin gives a user their first password, and is // restricted to administrators: it hands over an account outright, without // knowing the password it replaces. // // Every other session of the target is ended: a password change is what you // do when you think someone else is signed in. func handleSetPassword(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } if !requireSelfOrAdmin(w, r, id) { return } var req struct { Password string `json:"password"` CurrentPassword string `json:"current_password"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } if msg := validatePassword(req.Password); msg != "" { respond(w, http.StatusBadRequest, errResp(msg)) return } var existing sql.NullString err = db.QueryRowContext(r.Context(), "SELECT password_hash FROM users WHERE id = $1", id).Scan(&existing) if errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusNotFound, errResp("user not found")) return } if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } caller, _ := userFromContext(r.Context()) if caller.ID == id && existing.Valid && bcrypt.CompareHashAndPassword([]byte(existing.String), []byte(req.CurrentPassword)) != nil { respond(w, http.StatusForbidden, errResp("current password is incorrect")) return } hash, err := hashPassword(req.Password) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } tx, err := db.BeginTx(r.Context(), nil) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } defer tx.Rollback() if _, err := tx.ExecContext(r.Context(), "UPDATE users SET password_hash = $1 WHERE id = $2", hash, id); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key if _, err := tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1 AND id != $2", id, keep); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if err := tx.Commit(); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } w.WriteHeader(http.StatusNoContent) } } // purgeSessions deletes sessions that have expired, from the sweeper. func purgeSessions(ctx context.Context, db *sql.DB) { res, err := db.ExecContext(ctx, "DELETE FROM sessions WHERE expires_at < $1", time.Now().Unix()) if err != nil { log.Printf("sweeper: purge sessions: %v", err) return } if n, _ := res.RowsAffected(); n > 0 { log.Printf("sweeper: purged %d expired session(s)", n) } }