From 6afef85b2ba7609edca5e571fc29b3be8f9cfc59 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 20:37:46 +0300 Subject: [PATCH] Full-access tokens manage users, passwords and tokens --- README.md | 8 +++++--- api.go | 38 +++++++++++++++++++++++++++----------- main_test.go | 6 ++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a48beba..23be5ff 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,9 @@ Base path `/api/v1`. The web interface signs in with a session cookie; every user is an admin. Apps and scripts use `Authorization: Bearer `; create the token under Settings → Pair iOS app. A token belongs to the user who made it and is revoked when that user is deleted. A read-only token may only use -GET. Full-access tokens can do everything the web interface does except the -endpoints marked "signed in": users, passwords, API tokens, backup and restore. +GET. Full-access tokens can do everything the web interface does except backup +and restore. Users, passwords and API tokens need a full-access token even for +reading. `POST /users` and `POST /users/{id}/reset-password` take `{"password": "…", "mustChangePassword": true}`; with `true` (the default) the @@ -252,7 +253,8 @@ GET /peers/{id}/latency (24 h, one point per 5 minutes) GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup GET /settings PATCH /settings POST /restart GET /logs?level=&limit=&audit=1 GET /logs/download -signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore +GET /tokens POST /tokens DELETE /tokens/{id} +signed in: GET /backup · POST /restore public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens) ``` diff --git a/api.go b/api.go index b69e8cd..0670827 100644 --- a/api.go +++ b/api.go @@ -92,6 +92,17 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc { } } +// fullAccess refuses read-only tokens, also for GET. +func fullAccess(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if who(r).Scope == "ro" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"}) + return + } + h(w, r) + } +} + // applyResult saves-then-applies: the config is already stored, so a kernel // error is reported but does not undo the change. func (a *App) apply() string { @@ -105,16 +116,18 @@ func (a *App) routes() http.Handler { mux := http.NewServeMux() g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) } adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) } + // full is for signed-in users and full-access tokens, even for reading. + full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) } mux.HandleFunc("POST /api/v1/auth/login", a.login) mux.HandleFunc("POST /api/v1/auth/logout", a.logout) g("GET /api/v1/auth/me", a.me) - adm("POST /api/v1/auth/password", a.changePassword) - adm("GET /api/v1/users", a.listUsers) - adm("POST /api/v1/users", a.createUser) - adm("PATCH /api/v1/users/{id}", a.patchUser) - adm("POST /api/v1/users/{id}/reset-password", a.resetPassword) - adm("DELETE /api/v1/users/{id}", a.deleteUser) + full("POST /api/v1/auth/password", a.changePassword) + full("GET /api/v1/users", a.listUsers) + full("POST /api/v1/users", a.createUser) + full("PATCH /api/v1/users/{id}", a.patchUser) + full("POST /api/v1/users/{id}/reset-password", a.resetPassword) + full("DELETE /api/v1/users/{id}", a.deleteUser) g("GET /api/v1/status", a.status) g("GET /api/v1/stats", a.allStats) @@ -143,14 +156,14 @@ func (a *App) routes() http.Handler { mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo) mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem) - // Full-access tokens (the iOS app) may change app settings and read logs. - // Users, passwords, tokens and backups need a signed-in user. + // Full-access tokens (the iOS app) may change app settings, read logs and + // manage users and tokens. Backups need a signed-in user. g("GET /api/v1/settings", a.getSettings) g("PATCH /api/v1/settings", a.patchSettings) g("POST /api/v1/restart", a.restart) - adm("GET /api/v1/tokens", a.listTokens) - adm("POST /api/v1/tokens", a.createToken) - adm("DELETE /api/v1/tokens/{id}", a.deleteToken) + full("GET /api/v1/tokens", a.listTokens) + full("POST /api/v1/tokens", a.createToken) + full("DELETE /api/v1/tokens/{id}", a.deleteToken) g("GET /api/v1/logs", a.logs) g("GET /api/v1/logs/download", a.downloadLog) adm("GET /api/v1/backup", a.backup) @@ -226,6 +239,9 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) { "id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session, } + if p.TokenID != "" { + out["tokenId"] = p.TokenID // lets an app find its own token in /tokens + } if _, u := a.store.Get().userByID(p.UserID); u != nil { out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created } diff --git a/main_test.go b/main_test.go index 7b1f89c..e964f77 100644 --- a/main_test.go +++ b/main_test.go @@ -328,6 +328,12 @@ func TestAPI(t *testing.T) { bearer("GET", "/tokens", 403) bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device + // A full-access token manages users and tokens, but not backups. + secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string) + bearer("GET", "/users", 200) + bearer("GET", "/tokens", 200) + bearer("GET", "/backup", 403) + call("DELETE", "/peers/"+id, nil, 200) if len(store.Get().Peers) != 0 { t.Fatal("peer not deleted")