774fdfcaa8
ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams
entry, service account) used to be two parallel, un-unified context
representations -- every authz predicate had to remember which one(s) it
needed, and every place that forgot either wrongly 403'd a service account
(terdut-server#23, terdut-operator#3), crashed on an unchecked zero-value
user id, or silently no-op'd. New internal/api/caller.go collapses both
into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount;
every existing predicate (userFromContext, callerTeamIDs, callerRole,
callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin,
requireTeamOwner, OperatorModeBlock) now reads through it, with identical
behavior for every untouched call site (alerts.go, incidents.go,
schedule.go, stats.go, etc.) -- confirmed by the full existing suite
passing unchanged.
Four real fixes land alongside the refactor, not just the restructuring:
1. callerMayManageServiceAccount gains the one load-bearing branch this
exists for: an instance-scoped service account may now manage (mint or
revoke a key on) any team-scoped account, not only a human admin, that
team's human owner, or the account itself. handleCreateServiceAccount
already let an instance-scoped caller *create* a team-scoped account for
any team; adopting or rotating one it didn't just create in the same
call -- terdut-operator's own documented crash-window recovery -- had no
equivalent permission and 403'd forever. Closes terdut-operator#3.
2. handleCreateInvite wrote a service-account caller's zero-value user id
straight into invites.created_by (nullable, but never passed as nil),
which foreign-key-violates against users(id) -- a 500, not success, for
any team-scoped service account minting an invite. Fixed the same way
handleCreateServiceAccount already handles the analogous case. Found
live while verifying this change, not filed separately since it's fixed
in the same place it was found.
3. handleMe and handleTestNotification 500'd for a service-account caller
(fetchUser/the ntfy_topic lookup against a zero-value user id that
matches no row); handleDismissOnboarding silently no-op'd (UPDATE ...
WHERE id = 0). All three now call Caller.AsHuman() and return an
explicit 403 ("this endpoint is for human accounts only").
4. Ratifies, rather than further narrows, two capabilities a team-scoped
service account already had by construction and this document's own
text once called "a gap acknowledged rather than closed": owner-equivalent
reach over membership/invites, and minting another service account for
its own team. terdut-operator's new TerdutTeam invite-minting feature is
about to depend on the first one, so this makes it documented, tested,
intentional behavior instead of an accident nobody was supposed to rely
on.
AdminOnly/requireSelfOrAdmin are unchanged in effect: still human-only,
forever, for every scope of service account -- confirmed by
TestAdminOnly_RefusesEveryServiceAccountScope. terdut-server#23's named
routes (POST /api/users, PUT /api/admin/settings) were never the right
thing to widen; its real fix is the terdut-operator invite feature,
recorded in SERVICE-ACCOUNTS.md's "What this unblocks" and closing that
issue once it ships.
SERVICE-ACCOUNTS.md amended in place (not a new file, its own established
convention) to describe the as-built Caller model, correct its own
aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged
as not matching shipped code, and record all of the above.
401 lines
12 KiB
Go
401 lines
12 KiB
Go
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 {
|
|
return startSessionCapped(w, r, db, userID, publicURL, 0)
|
|
}
|
|
|
|
// startSessionCapped is startSession with a hard ceiling on the session's life,
|
|
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
|
|
// login uses it: the login is the only moment the provider's groups are read, so
|
|
// a session that could outlive it indefinitely would keep access the provider
|
|
// has since taken away.
|
|
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
|
|
raw, tokenHash, err := randomToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
life := sessionTTL
|
|
var ceiling *int64
|
|
if maxAge > 0 {
|
|
c := now.Add(maxAge).Unix()
|
|
ceiling = &c
|
|
life = min(life, maxAge)
|
|
}
|
|
if _, err := db.ExecContext(r.Context(), `
|
|
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
|
|
return err
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: raw,
|
|
Path: "/",
|
|
MaxAge: int(life.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, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
}
|