Files
terdut-server/internal/api/middleware.go
T
Niklas Ye 1377d9005b
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m37s
Add a system administrator role, and gate account management behind it
Until now every authenticated caller could create and delete users, set
anybody's password and mint anybody's API keys -- auth.go said so in a
comment. Defensible with one operator and a hand-made account; not once
people sign themselves up (#7), and not in a multi-tenant install (#4),
where the user list is no longer everybody who works here.

users.is_admin is the flag. AdminOnly gates creating and deleting users
and granting the flag itself. The endpoints that are self-service for
your own account and administration for somebody else's -- password,
ntfy topic, API keys -- go through requireSelfOrAdmin instead, because
which rule applies depends on the {id} in the path rather than on the
route.

Minting your own API key stays self-service. A key carries exactly the
rights of the user it belongs to, so issuing one is no more than signing
in again; requiring an admin for it would mean a responder cannot set up
the TUI without somebody else in the room.

/api/users stays readable by everybody. The queue's assignment control
and the on-call schedule both have to name people, and hiding the roster
from the people on it buys nothing.

THE MIGRATION MAKES EVERY EXISTING USER AN ADMINISTRATOR. They already
hold these powers, so nobody's access changes on upgrade: it names what
is already true and leaves demotion as a deliberate act. Promoting only
user 1 would silently strip the others, and could leave an install whose
only administrator is an account nobody has a password for.

Two guards keep an install administrable: the last administrator can be
neither deleted nor demoted, and nobody can delete or demote themselves
-- the likelier accident, where the only admin clears their own flag
while tidying up and locks the door behind them.

No UI changes: there are no account-management screens yet. models.User
carries is_admin (not omitempty, so a client can tell false from an old
server), which is what #5's admin page will render from.
2026-09-20 13:20:04 +02:00

177 lines
5.8 KiB
Go

package api
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
type contextKey string
const (
ctxUser contextKey = "user"
ctxSession contextKey = "session"
)
// AuthMiddleware accepts either of the two credentials the server issues: an
// API key in an Authorization header (the TUI, scripts) or a session cookie
// (the web UI). A request carrying a Bearer header is judged on that alone and
// never falls back to the cookie.
//
// Only the cookie needs a CSRF guard. A browser attaches it to requests other
// sites make, whereas an Authorization header is only ever set by the client
// that holds the key.
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
crossOrigin := http.NewCrossOriginProtection()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if header := r.Header.Get("Authorization"); header != "" {
token, ok := strings.CutPrefix(header, "Bearer ")
if !ok || token == "" {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
userID, ok := apiKeyUser(r.Context(), db, token)
if !ok {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
serveAs(w, r, next, db, userID, 0)
return
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
sessionID, userID, ok := sessionUser(r.Context(), db, c.Value)
if !ok {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
if err := crossOrigin.Check(r); err != nil {
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
return
}
serveAs(w, r, next, db, userID, sessionID)
})
}
}
// AdminOnly rejects a caller who is not a system administrator. It runs inside
// AuthMiddleware's group, so by the time it sees a request the caller is known.
//
// 403 and not 404: the route exists and the caller is authenticated, they are
// simply not allowed. Hiding the endpoint would buy nothing — every one of them
// is in the README.
func AdminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
caller, ok := userFromContext(r.Context())
if !ok || !caller.IsAdmin {
respond(w, http.StatusForbidden, errResp("administrator access required"))
return
}
next.ServeHTTP(w, r)
})
}
// requireSelfOrAdmin guards the endpoints that are self-service for your own
// account and administration for anybody else's: your password, your ntfy
// topic, your API keys. Reports whether the request may proceed, and answers it
// if not.
//
// An API key is not an escalation: it carries exactly the rights of the user it
// belongs to, so minting your own is no more than signing in again.
func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool {
caller, ok := userFromContext(r.Context())
if !ok || (caller.ID != targetID && !caller.IsAdmin) {
respond(w, http.StatusForbidden, errResp("administrator access required"))
return false
}
return true
}
// apiKeyUser resolves an API key to its user and stamps its last use.
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
var keyID, userID int64
err := db.QueryRowContext(ctx,
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
).Scan(&keyID, &userID)
if err != nil {
return 0, false
}
// best-effort; don't fail the request if this update fails
db.ExecContext(ctx,
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
time.Now().Unix(), keyID)
return userID, true
}
// sessionUser resolves a session token to its session and user. The expiry
// slides forward with use, but at most once per sessionTouchEvery, so a page
// that polls does not write to the database on every request.
func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, userID int64, ok bool) {
now := time.Now()
var lastSeen int64
err := db.QueryRowContext(ctx, `
SELECT id, user_id, last_seen_at FROM sessions
WHERE token_hash = $1 AND expires_at > $2`,
hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen)
if err != nil {
return 0, 0, false
}
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
db.ExecContext(ctx,
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
}
return sessionID, userID, true
}
// serveAs loads the user and hands the request on with it in the context.
// sessionID is zero for API-key requests.
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
var u models.User
var createdUnix int64
if err := db.QueryRowContext(r.Context(),
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", userID,
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
ctx := context.WithValue(r.Context(), ctxUser, u)
if sessionID != 0 {
ctx = context.WithValue(ctx, ctxSession, sessionID)
}
next.ServeHTTP(w, r.WithContext(ctx))
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func userFromContext(ctx context.Context) (models.User, bool) {
u, ok := ctx.Value(ctxUser).(models.User)
return u, ok
}
// sessionFromContext returns the id of the session a request was authenticated
// with, or false for an API-key request.
func sessionFromContext(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(ctxSession).(int64)
return id, ok
}