diff --git a/README.md b/README.md index ce5e4f2..a975fae 100644 --- a/README.md +++ b/README.md @@ -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 `; 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. diff --git a/api.go b/api.go index ebef872..9d0686f 100644 --- a/api.go +++ b/api.go @@ -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 } @@ -1008,15 +993,14 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) { func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() writeJSON(w, http.StatusOK, map[string]any{ - "web": cfg.Web, - "log": cfg.Log, - "stats": cfg.Stats, - "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, + "web": cfg.Web, + "log": cfg.Log, + "stats": cfg.Stats, + "decoy": cfg.Decoy, + "signin": cfg.SignIn, + "geo": a.geoStatus(), + "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 diff --git a/main_test.go b/main_test.go index 2a369be..94f653c 100644 --- a/main_test.go +++ b/main_test.go @@ -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