API tokens no longer manage users, passwords or tokens
A full-access token could create a user or reset a password, sign in as that user and so reach backups and two-step sign-in settings. Users, passwords, API tokens and the sign-in rules in PATCH /settings now need a signed-in user again. /auth/me no longer returns tokenId, and /settings no longer returns adminUsername.
This commit is contained in:
@@ -227,27 +227,28 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
|
||||
Base path `/api/v1`. The web interface signs in with a session cookie; every
|
||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||
the token under Settings → Pair iOS app, or in the iOS app under Settings →
|
||||
Access → API tokens. A token belongs to the user who made
|
||||
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 backup
|
||||
and restore. Users, passwords and API tokens need a full-access token even for
|
||||
reading.
|
||||
GET. Full-access tokens can do everything the web interface does except the
|
||||
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
|
||||
backup and restore.
|
||||
|
||||
For a user with two-step sign-in, `POST /auth/login` answers
|
||||
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
||||
instead of starting a session; the ticket is good for 5 minutes, and one of
|
||||
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
|
||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
|
||||
only a signed-in user can change it.
|
||||
|
||||
`POST /users` and `POST /users/{id}/reset-password` take
|
||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||
user can do nothing but choose a new password at the next sign-in.
|
||||
|
||||
```
|
||||
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
|
||||
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
|
||||
POST /users/{id}/reset-password POST /users/{id}/reset-mfa
|
||||
POST /auth/login · /auth/logout GET /auth/me
|
||||
signed in: POST /auth/password (own password)
|
||||
signed in: GET|POST /users · PATCH|DELETE /users/{id}
|
||||
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
|
||||
GET /auth/options (public: is passkey sign-in offered here)
|
||||
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
||||
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
||||
@@ -265,8 +266,7 @@ 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
|
||||
GET /tokens POST /tokens DELETE /tokens/{id}
|
||||
signed in: GET /backup · POST /restore
|
||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||
```
|
||||
|
||||
@@ -296,9 +296,9 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
|
||||
## iOS app
|
||||
|
||||
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
||||
GHOSTWIRE-Companion. It does everything the web interface does except
|
||||
backup and restore, and adding an authenticator app or passkeys for two-step
|
||||
sign-in. Pair it in the web interface under
|
||||
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
|
||||
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
|
||||
and restore stay in the web interface. Pair it in the web interface under
|
||||
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||
pairing.
|
||||
|
||||
@@ -97,17 +97,6 @@ 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 {
|
||||
@@ -121,8 +110,6 @@ 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)
|
||||
@@ -146,13 +133,13 @@ func (a *App) routes() http.Handler {
|
||||
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
||||
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
||||
g("GET /api/v1/auth/me", a.me)
|
||||
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)
|
||||
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
||||
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)
|
||||
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
||||
|
||||
g("GET /api/v1/status", a.status)
|
||||
g("GET /api/v1/stats", a.allStats)
|
||||
@@ -182,13 +169,14 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||
|
||||
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||
// manage users and tokens. Backups need a signed-in user.
|
||||
// restart. Users, passwords, API tokens, the sign-in rules and 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)
|
||||
full("GET /api/v1/tokens", a.listTokens)
|
||||
full("POST /api/v1/tokens", a.createToken)
|
||||
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||
adm("GET /api/v1/tokens", a.listTokens)
|
||||
adm("POST /api/v1/tokens", a.createToken)
|
||||
adm("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)
|
||||
@@ -270,9 +258,6 @@ 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, "mfaSetupRequired": p.MFASetupRequired, "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
|
||||
}
|
||||
@@ -1014,7 +999,6 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
"logPath": a.logPath,
|
||||
})
|
||||
@@ -1026,8 +1010,8 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if _, ok := m["adminUsername"]; ok {
|
||||
writeErr(w, badRequest("usernames are changed under /users"))
|
||||
if _, ok := m["signin"]; ok && !who(r).IsAdmin {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
|
||||
return
|
||||
}
|
||||
var restart bool
|
||||
|
||||
+20
-6
@@ -313,8 +313,13 @@ func TestAPI(t *testing.T) {
|
||||
secret := tok["token"].(string)
|
||||
|
||||
// Read-only token: GET works, changes are refused, admin endpoints too.
|
||||
bearer := func(method, path string, want int) {
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
|
||||
bearer := func(method, path string, want int, body ...any) {
|
||||
var rd io.Reader
|
||||
if len(body) > 0 {
|
||||
b, _ := json.Marshal(body[0])
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -330,10 +335,20 @@ 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.
|
||||
// A full-access token changes settings, but users, passwords, tokens,
|
||||
// the sign-in rules and backups need a signed-in user.
|
||||
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
||||
bearer("GET", "/users", 200)
|
||||
bearer("GET", "/tokens", 200)
|
||||
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
|
||||
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
|
||||
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
|
||||
bearer("GET", "/users", 403)
|
||||
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
|
||||
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
|
||||
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
|
||||
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
|
||||
bearer("GET", "/tokens", 403)
|
||||
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
||||
bearer("GET", "/backup", 403)
|
||||
|
||||
call("DELETE", "/peers/"+id, nil, 200)
|
||||
@@ -969,7 +984,6 @@ func TestUsers(t *testing.T) {
|
||||
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
|
||||
t.Fatalf("users: %d, want 2", n)
|
||||
}
|
||||
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
|
||||
}
|
||||
|
||||
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
||||
|
||||
Reference in New Issue
Block a user