Files
terdut-server/internal/api/device.go
T
Niklas Ye 9029d48584
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s
Let an operator authenticate with a seeded key, and reset the schema
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
  "terdut-operator" at every start, so terdut-operator needs no bootstrap
  handshake. An instance-scoped account now acts as owner of every team's
  configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
  is idempotent on it, so automation finds its own team again after a crash
  instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
  escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
  TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
  their own. Existing development databases must be recreated.

Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
  non-admins.
- The access log records the route pattern, so integration keys and ack
  tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
  right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
  concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.

Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-09 14:56:13 +02:00

283 lines
8.8 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 {
serverError(w, r, err)
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)
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
return
}
if err := tx.Commit(); err != nil {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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)
serverError(w, r, err)
return
}
user, err := fetchUser(r.Context(), db, userID.Int64)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
}
}