926aa2d3ec
Part of the same security-hardening pass as the last five commits, and
the last item in its backlog. User API keys had no expiry at all --
unlike service-account keys, visibly distinct only by their "tdsa_"
prefix -- and, it turns out while implementing this, no way to list
them either: only create (returns the raw key once) and delete-by-id
existed, so a key's owner had no way to even discover what keys they
had short of remembering IDs from creation time.
handleCreateAPIKey takes an optional expires_in_days (0, the default,
keeps today's behavior: never expires, so no existing integration is
affected). apiKeyUser's lookup now carries `expires_at IS NULL OR
expires_at > now` as part of the query itself, the same way serveAs's
disabled_at check already works -- an expired key simply fails to
resolve, like a wrong one, rather than resolving and being caught
after the fact. New GET /api/users/{id}/api-keys (requireSelfOrAdmin,
same as create/delete) lists id/name/created_at/last_used_at/expires_at,
never the raw key.
Scoped down from the original plan on request: no web UI change, since
there turned out to be no existing API-keys UI at all to extend --
building one from scratch would have been a real feature addition, not
a hardening tweak.
Mirrored the additive expires_at field in terdut-tui's APIKey struct
(separate commit, separate repo) per this workspace's version-coupling
rule; the TUI does not create or list expiring keys itself yet.
New tests (api_keys_test.go): default never-expires, expires_in_days
sets expires_at, out-of-range values rejected, an expired key fails
auth after a fresh one worked, the listing never includes the raw key.
Also added the new GET route to authz_scope_test.go's self-or-admin
table from the previous commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
492 lines
16 KiB
Go
492 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
// Password is optional; without one the first user can only use the
|
|
// API key until somebody sets it.
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
var passwordHash *string
|
|
if req.Password != "" {
|
|
if msg := validatePassword(req.Password); msg != "" {
|
|
respond(w, http.StatusBadRequest, errResp(msg))
|
|
return
|
|
}
|
|
h, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
passwordHash = &h
|
|
}
|
|
|
|
var count int
|
|
if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if count > 0 {
|
|
respond(w, http.StatusForbidden, errResp("bootstrap already completed"))
|
|
return
|
|
}
|
|
|
|
var userID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO users (username, email, password_hash, is_admin) VALUES ($1, $2, $3, true) RETURNING id",
|
|
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
var keyID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
|
userID, hash, "bootstrap").Scan(&keyID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
// The default team exists from migration 003, on a fresh install too.
|
|
// Without a membership the first user signs in to a working server with
|
|
// no queue, no schedule and nowhere for an integration to hang off.
|
|
if teamID, err := defaultTeamID(r.Context(), db); err == nil {
|
|
db.ExecContext(r.Context(), //nolint:errcheck
|
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) "+
|
|
"ON CONFLICT (team_id, user_id) DO NOTHING",
|
|
teamID, userID, models.RoleOwner)
|
|
}
|
|
|
|
user, _ := fetchUser(r.Context(), db, userID)
|
|
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
|
respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key})
|
|
}
|
|
}
|
|
|
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.QueryContext(r.Context(),
|
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := []models.User{}
|
|
for rows.Next() {
|
|
var u models.User
|
|
var ts int64
|
|
var disabled *int64
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
|
u.DisabledAt = unixPtr(disabled)
|
|
users = append(users, u)
|
|
}
|
|
respond(w, http.StatusOK, users)
|
|
}
|
|
}
|
|
|
|
func handleCreateUser(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
|
|
var id int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
|
|
req.Username, req.Email).Scan(&id); err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
|
return
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
user, _ := fetchUser(r.Context(), db, id)
|
|
respond(w, http.StatusCreated, user)
|
|
}
|
|
}
|
|
|
|
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
|
|
// clears it with an empty string. The topic is a shared secret with the ntfy
|
|
// server — anyone who knows it can publish to it — so pick an unguessable one
|
|
// unless your ntfy enforces access control.
|
|
func handleSetNotifyTarget(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 {
|
|
NtfyTopic string `json:"ntfy_topic"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
|
|
var topic *string
|
|
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
|
|
topic = &t
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
|
|
user, err := fetchUser(r.Context(), db, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, user)
|
|
}
|
|
}
|
|
|
|
func handleDeleteUser(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
|
|
}
|
|
// Deleting yourself is how an install ends up with no administrator at
|
|
// all, and it is never what somebody meant to do.
|
|
caller, _ := userFromContext(r.Context())
|
|
if caller.ID == id {
|
|
respond(w, http.StatusConflict, errResp("cannot delete your own account"))
|
|
return
|
|
}
|
|
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
} else if last {
|
|
respond(w, http.StatusConflict, errResp("cannot delete the last administrator"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
|
|
// rotation policy, tight enough to reject a typo (a year in hours, say) that
|
|
// would otherwise mint a key that outlives the server by decades.
|
|
const maxAPIKeyExpiryDays = 3650 // ~10 years
|
|
|
|
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, 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, userID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
// ExpiresInDays is optional and, left zero, means the key never
|
|
// expires — the only behavior any key had before this field
|
|
// existed, so an existing integration that does not send it is
|
|
// unaffected.
|
|
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
|
|
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
|
|
return
|
|
}
|
|
|
|
var exists int
|
|
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
var expiresAt *int64
|
|
var expiresAtTime *time.Time
|
|
if req.ExpiresInDays > 0 {
|
|
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
|
|
u := t.Unix()
|
|
expiresAt = &u
|
|
expiresAtTime = &t
|
|
}
|
|
var keyID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
key := models.APIKey{
|
|
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
|
|
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
|
|
}
|
|
respond(w, http.StatusCreated, key)
|
|
}
|
|
}
|
|
|
|
// handleListAPIKeys lists a user's own API keys: never the raw key itself
|
|
// (only ever returned once, at creation), just enough to tell them apart,
|
|
// see which are stale (last_used_at) and which are about to stop working
|
|
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
|
|
// already produce, with no endpoint to read it back until now.
|
|
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, 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, userID) {
|
|
return
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(),
|
|
`SELECT id, name, created_at, last_used_at, expires_at
|
|
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
keys := []models.APIKey{}
|
|
for rows.Next() {
|
|
var k models.APIKey
|
|
var created int64
|
|
var lastUsed, expires *int64
|
|
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
k.UserID = userID
|
|
k.CreatedAt = time.Unix(created, 0).UTC()
|
|
k.LastUsedAt = unixPtr(lastUsed)
|
|
k.ExpiresAt = unixPtr(expires)
|
|
keys = append(keys, k)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, keys)
|
|
}
|
|
}
|
|
|
|
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, 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, userID) {
|
|
return
|
|
}
|
|
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("api key not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// randomToken generates a random 32-byte secret encoded as hex, plus its SHA-256
|
|
// hash for storage. Used for API keys and for notification acknowledge tokens.
|
|
func randomToken() (raw, hash string, err error) {
|
|
b := make([]byte, 32)
|
|
if _, err = rand.Read(b); err != nil {
|
|
return
|
|
}
|
|
raw = hex.EncodeToString(b)
|
|
h := sha256.Sum256([]byte(raw))
|
|
hash = hex.EncodeToString(h[:])
|
|
return
|
|
}
|
|
|
|
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
|
var u models.User
|
|
var ts int64
|
|
var disabled *int64
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
|
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
|
|
if err != nil {
|
|
return u, err
|
|
}
|
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
|
u.DisabledAt = unixPtr(disabled)
|
|
return u, nil
|
|
}
|
|
|
|
// handleSetAdmin grants or revokes the system administrator flag.
|
|
//
|
|
// Revoking is guarded twice: an install must keep at least one administrator,
|
|
// and you cannot demote yourself. The first stops the flag being lost
|
|
// altogether; the second stops the likelier accident, where the only admin
|
|
// clears their own flag while tidying up and locks the door behind them.
|
|
func handleSetAdmin(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
|
|
}
|
|
var req struct {
|
|
IsAdmin *bool `json:"is_admin"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil {
|
|
respond(w, http.StatusBadRequest, errResp("is_admin is required"))
|
|
return
|
|
}
|
|
|
|
if !*req.IsAdmin {
|
|
var managed bool
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
|
|
id).Scan(&managed); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if managed {
|
|
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
|
|
return
|
|
}
|
|
|
|
caller, _ := userFromContext(r.Context())
|
|
if caller.ID == id {
|
|
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
|
return
|
|
}
|
|
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
} else if last {
|
|
respond(w, http.StatusConflict, errResp("cannot revoke the last administrator"))
|
|
return
|
|
}
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
|
|
user, err := fetchUser(r.Context(), db, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, user)
|
|
}
|
|
}
|
|
|
|
// isLastAdmin reports whether id is an administrator and no other user is one.
|
|
// A non-admin id is never the last one, so removing them is always allowed.
|
|
func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) {
|
|
var last bool
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin)
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last)
|
|
return last, err
|
|
}
|