From 4ecfddf06a8489e9a4b358c3c416ea335f2d96ca Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 15:51:14 +0300 Subject: [PATCH] Multiple users, all admins The single admin account becomes a list of users; config.json moves to version 2 and the old admin is migrated on first start. Every user is an admin. Sessions are tied to a user and their password, so deleting a user or resetting a password signs them out at once. API tokens belong to the user who made them and go away with that user. Admins add users with a temporary password and choose whether it must be changed at first sign-in; until then the API refuses everything but the password change. Settings gets My account and Users cards, and the token table shows each token's owner. 'GHOSTWIRE passwd [username]' resets any user's password. A failed update now also restores config.json, since the new version may have upgraded it. --- README.md | 32 +++++--- api.go | 77 ++++++++++++------ app.css | 7 ++ app.js | 207 ++++++++++++++++++++++++++++++++++++++++++++----- auth.go | 86 ++++++++++++++++----- config.go | 113 +++++++++++++++++++++++---- installask.go | 10 +-- main.go | 36 ++++++--- main_test.go | 139 ++++++++++++++++++++++++++++++++- setup.go | 17 ++-- users.go | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 825 insertions(+), 109 deletions(-) create mode 100644 users.go diff --git a/README.md b/README.md index b7e6d6f..04405f1 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies on the server: the binary installs, updates and removes itself. - **The service is not root.** It runs as user `ghostwire` with only `CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to `/opt/ghostwire`. -- **Sign-in:** one admin account. The password is stored as an argon2id hash. +- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes. After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an HttpOnly, SameSite=Strict cookie and last 12 hours by default. - **API tokens** are stored only as hashes and can be read-only or full access. @@ -150,17 +150,18 @@ questions offer the current settings, so Enter keeps them. If a changed endpoint or port means existing devices need a new config, the summary says how many. -Then open `https://vpn.example.net` and sign in as `admin`. Root is needed only -for the commands below, never for the running service. +Then open `https://vpn.example.net` and sign in as `admin`. Add more users +under Settings → Users. Root is needed only for the commands below, never for +the running service. ## Commands (as root) | Command | What it does | |---|---| | `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. | -| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary is put back and restarted. It refuses older versions without `-force`. | +| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. | | `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. | -| `GHOSTWIRE passwd` | Sets the admin password and reloads the running service. | +| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. | | `GHOSTWIRE version` | Prints the version. | Updating restarts only the management service. VPN connections stay up, @@ -207,14 +208,21 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`. ## API -Base path `/api/v1`. The web interface signs in with a session cookie. Apps and -scripts use `Authorization: Bearer `; create the token under Settings → -Pair iOS app. A read-only token may only use GET. Full-access tokens can do -everything the web interface does except the admin-only endpoints: password, -API tokens, backup and restore, and changing the admin username. +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. + +`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 (admin) +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 GET /status GET /stats?range=24h|7d|30d|90d GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip GET /peers POST /peers (returns the config and QR once) @@ -225,7 +233,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 -admin: GET|POST /tokens · DELETE /tokens/{id} · 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) ``` diff --git a/api.go b/api.go index 24b4f1f..40f470e 100644 --- a/api.go +++ b/api.go @@ -77,7 +77,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc { return } if adminOnly && !p.IsAdmin { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the admin account can do this"}) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot do this; sign in to the web interface"}) + return + } + if p.MustChangePassword && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"}) return } if p.Scope == "ro" && r.Method != http.MethodGet { @@ -106,6 +110,11 @@ func (a *App) routes() http.Handler { 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) g("GET /api/v1/status", a.status) g("GET /api/v1/stats", a.allStats) @@ -135,7 +144,7 @@ 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 and read logs. - // Password, tokens and backups stay with the admin account. + // Users, passwords, tokens 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) @@ -190,15 +199,18 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) { writeJSON(w, code, map[string]string{"error": err.Error()}) return } - cfg := a.store.Get() - http.SetCookie(w, &http.Cookie{ - Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil, - SameSite: http.SameSiteStrictMode, MaxAge: cfg.Web.SessionHours * 3600, - }) + a.setSessionCookie(w, r, id) slog.Info("login", "audit", true, "actor", in.Username, "remote", ip) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (a *App) setSessionCookie(w http.ResponseWriter, r *http.Request, id string) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil, + SameSite: http.SameSiteStrictMode, MaxAge: a.store.Get().Web.SessionHours * 3600, + }) +} + func (a *App) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName()); err == nil { a.auth.Logout(c.Value) @@ -209,19 +221,30 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) { func (a *App) me(w http.ResponseWriter, r *http.Request) { p := who(r) - writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "version": version}) + writeJSON(w, http.StatusOK, map[string]any{ + "id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, + "mustChangePassword": p.MustChangePassword, "version": version, + }) } +// changePassword changes the signed-in user's own password. Their other +// sessions end; this one continues with a new session id. func (a *App) changePassword(w http.ResponseWriter, r *http.Request) { var in struct{ Current, New string } if err := readJSON(r, &in); err != nil { writeErr(w, err) return } - if !verifyPassword(a.store.Get().Admin.PasswordHash, in.Current) { + id := who(r).UserID + _, u := a.store.Get().userByID(id) + if u == nil || !verifyPassword(u.PasswordHash, in.Current) { writeErr(w, badRequest("current password is wrong")) return } + if in.New == in.Current { + writeErr(w, badRequest("choose a password different from the current one")) + return + } if err := validatePassword(in.New); err != nil { writeErr(w, err) return @@ -231,12 +254,24 @@ func (a *App) changePassword(w http.ResponseWriter, r *http.Request) { writeErr(w, err) return } - if err := a.store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; return nil }); err != nil { + var updated User + if err := a.store.Update(func(c *Config) error { + _, u := c.userByID(id) + if u == nil { + return badRequest("no such user") + } + u.PasswordHash, u.MustChangePassword = hash, false + updated = *u + return nil + }); err != nil { writeErr(w, err) return } + if c, err := r.Cookie(cookieName()); err == nil { + a.auth.Logout(c.Value) + } + a.setSessionCookie(w, r, a.auth.NewSession(&updated)) a.audit(r, "password changed") - a.auth.DropSessions() writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } @@ -921,7 +956,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { "log": cfg.Log, "stats": cfg.Stats, "geo": a.geoStatus(), - "adminUsername": cfg.Admin.Username, + "adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions "fingerprint": a.tls.Fingerprint(), "logPath": a.logPath, }) @@ -933,18 +968,12 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) { writeErr(w, err) return } - if _, ok := m["adminUsername"]; ok && !who(r).IsAdmin { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the admin account can change the username"}) + if _, ok := m["adminUsername"]; ok { + writeErr(w, badRequest("usernames are changed under /users")) return } var restart bool err = a.store.Update(func(c *Config) error { - if err := field(m, "adminUsername", &c.Admin.Username); err != nil { - return err - } - if strings.TrimSpace(c.Admin.Username) == "" { - return badRequest("username cannot be empty") - } before, _ := json.Marshal(c.Web) if err := field(m, "web", &c.Web); err != nil { return err @@ -978,14 +1007,16 @@ type tokenView struct { ID string `json:"id"` Name string `json:"name"` Scope string `json:"scope"` + Owner string `json:"owner"` // username Created time.Time `json:"created"` LastUsed *tokenUse `json:"lastUsed"` } func (a *App) listTokens(w http.ResponseWriter, r *http.Request) { + cfg := a.store.Get() out := []tokenView{} - for _, t := range a.store.Get().APITokens { - out = append(out, tokenView{t.ID, t.Name, t.Scope, t.Created, a.auth.TokenUse(t.ID)}) + for _, t := range cfg.APITokens { + out = append(out, tokenView{t.ID, t.Name, t.Scope, a.username(cfg, t.UserID), t.Created, a.auth.TokenUse(t.ID)}) } writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) } @@ -1005,7 +1036,7 @@ func (a *App) createToken(w http.ResponseWriter, r *http.Request) { in.Scope = "rw" } secret := tokenPrefix + randomString(32) - t := APIToken{ID: newID(), Name: in.Name, Hash: hashToken(secret), Scope: in.Scope, Created: time.Now().UTC()} + t := APIToken{ID: newID(), Name: in.Name, Hash: hashToken(secret), Scope: in.Scope, UserID: who(r).UserID, Created: time.Now().UTC()} if err := a.store.Update(func(c *Config) error { c.APITokens = append(c.APITokens, t); return nil }); err != nil { writeErr(w, err) return diff --git a/app.css b/app.css index a94570e..b9b81b3 100644 --- a/app.css +++ b/app.css @@ -108,6 +108,8 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over .dot.bad { background: var(--bad); } .dot.warn { background: var(--up); } .dot.big { width: 10px; height: 10px; } +.tag.plain { background: #efefeb; color: #3d3e42; } +.badge.warn { background: var(--warn-bg); color: var(--warn-ink); } .tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; } .notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; } .notice.err { background: #fbefee; color: var(--bad-ink); } @@ -248,6 +250,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); } .signin:hover:not(:disabled) .en { display: none; } .signin:hover:not(:disabled) .ja { display: inline; } .loginform .err-text { color: #ff8a80; } +.logintext { display: flex; flex-direction: column; gap: 6px; text-align: center; margin-top: -8px; } +.logintext h1 { font-size: 20px; } +.logintext p { margin: 0; color: #c9c9c3; } +.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; } +.linkbtn:hover { color: #fff; } .loginform .err-text:empty { display: none; } .loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; } .err-text { color: var(--bad-ink); font-size: 13px; margin: 0; } diff --git a/app.js b/app.js index 1e80ef1..d447f65 100644 --- a/app.js +++ b/app.js @@ -106,6 +106,14 @@ } const list = (s) => s.split(',').map((x) => x.trim()).filter(Boolean); + + // newPassword makes a temporary password like "k7qm-x2pd-9nfh-tw4c", + // without look-alike characters. + function newPassword() { + const abc = 'abcdefghjkmnpqrstuvwxyz23456789'; + const r = crypto.getRandomValues(new Uint32Array(16)); + return Array.from(r, (n, i) => (i && i % 4 === 0 ? '-' : '') + abc[n % abc.length]).join(''); + } const sameList = (a, b) => JSON.stringify(a || null) === JSON.stringify(b || null); function peerState(p) { @@ -212,6 +220,10 @@ showLogin(); throw new Error('Signed out'); } + if (r.status === 403 && data.code === 'password_change_required') { + showNewPassword(); + throw new Error('Signed out'); + } if (!r.ok) throw new Error(data.error || r.statusText); return data; } @@ -512,6 +524,7 @@ if (!me) { try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; } } + if (me.mustChangePassword) { showNewPassword(); return; } if (!main || !main.isConnected) buildShell(); every(30000, refreshSide); const hash = location.hash || '#/'; @@ -540,7 +553,7 @@ cleanups = []; main = null; const err = h('p', { class: 'err-text', role: 'alert' }); - const user = h('input', { id: 'u', autocomplete: 'username', value: 'admin', required: true }); + const user = h('input', { id: 'u', autocomplete: 'username', autocapitalize: 'none', required: true }); const pw = h('input', { id: 'p', type: 'password', autocomplete: 'current-password', required: true }); // On hover the label turns into its Japanese reading; screen readers keep "Sign in". const btn = h('button', { type: 'submit', class: 'btn primary signin' }, @@ -552,7 +565,7 @@ try { await api('POST', '/auth/login', { username: user.value, password: pw.value }); me = await api('GET', '/auth/me'); - render(); + if (me.mustChangePassword) showNewPassword(pw.value); else render(); } catch (x) { err.textContent = x.message; btn.disabled = false; @@ -566,7 +579,47 @@ brand(72), form), h('p', { class: 'loginfoot' }, 'WireGuard server manager'))); - pw.focus(); + user.focus(); + } + + // showNewPassword is the screen after signing in with a temporary password + // an admin chose. current is that password when the user just typed it. + function showNewPassword(current) { + cleanups.forEach((f) => f()); + cleanups = []; + main = null; + const err = h('p', { class: 'err-text', role: 'alert' }); + const cur = current ? null : h('input', { id: 'pc', type: 'password', autocomplete: 'current-password', required: true }); + const p1 = h('input', { id: 'p1', type: 'password', autocomplete: 'new-password', placeholder: 'At least 12 characters', required: true }); + const p2 = h('input', { id: 'p2', type: 'password', autocomplete: 'new-password', required: true }); + const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Save and continue'); + const form = h('form', { class: 'loginform', onSubmit: async (e) => { + e.preventDefault(); + err.textContent = ''; + if (p1.value !== p2.value) { err.textContent = 'The passwords do not match'; return; } + btn.disabled = true; + try { + await api('POST', '/auth/password', { current: current || cur.value, new: p1.value }); + me = await api('GET', '/auth/me'); + render(); + } catch (x) { + err.textContent = x.message; + btn.disabled = false; + } + } }, + cur ? h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Temporary password'), cur) : null, + h('div', { class: 'field' }, h('label', { htmlFor: 'p1' }, 'New password'), p1), + h('div', { class: 'field' }, h('label', { htmlFor: 'p2' }, 'Repeat password'), p2), + err, btn, + h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out')); + app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' }, + brand(72), + h('div', { class: 'logintext' }, + h('h1', null, me ? 'Welcome, ' + me.name : 'Choose a new password'), + h('p', null, 'An admin gave you a temporary password. Choose your own to continue.')), + form), + h('p', { class: 'loginfoot' }, 'WireGuard server manager'))); + (cur || p1).focus(); } // ---------- dashboard ---------- @@ -1177,14 +1230,14 @@ async function viewSettings(wrap) { if (!me.isAdmin) { - fill(wrap, h('h1', null, 'Settings'), h('div', { class: 'notice' }, 'Only the admin account can change settings. API tokens cannot.')); + fill(wrap, h('h1', null, 'Settings'), h('div', { class: 'notice' }, 'API tokens cannot change settings. Sign in to the web interface.')); return; } - const [s, tk] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens')]); + const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]); let logLevelFilter = 'all'; - // admin account - const uname = h('input', { id: 'u', value: s.adminUsername, autocomplete: 'username' }); + // my account + const uname = h('input', { id: 'u', value: me.name, autocomplete: 'username', autocapitalize: 'none' }); const cur = h('input', { id: 'pc', type: 'password', autocomplete: 'current-password' }); const p1 = h('input', { id: 'p1', type: 'password', autocomplete: 'new-password', placeholder: 'At least 12 characters' }); const p2 = h('input', { id: 'p2', type: 'password', autocomplete: 'new-password' }); @@ -1193,20 +1246,127 @@ e.preventDefault(); accErr.textContent = ''; try { - if (uname.value.trim() !== s.adminUsername) { - await api('PATCH', '/settings', { adminUsername: uname.value.trim() }); - toast('Username saved'); - } if (p1.value || p2.value || cur.value) { if (p1.value !== p2.value) throw new Error('The new passwords do not match'); await api('POST', '/auth/password', { current: cur.value, new: p1.value }); - toast('Password changed. Please sign in again.'); - me = null; - showLogin(); + toast('Password changed. Other browsers are signed out.'); } + if (uname.value.trim() !== me.name) { + await api('PATCH', '/users/' + me.id, { username: uname.value.trim() }); + toast('Username saved'); + } + me = await api('GET', '/auth/me'); + main = null; + render(); } catch (x) { accErr.textContent = x.message; } }; + // users + const userBody = h('tbody'); + const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null, + h('td', null, h('strong', null, u.username), u.you ? h('span', { class: 'tag plain' }, 'You') : null, u.note ? h('div', { class: 'note' }, u.note) : null), + h('td', null, u.mustChangePassword ? h('span', { class: 'badge warn' }, 'Must choose a password') : h('span', { class: 'muted' }, 'Active')), + h('td', null, u.lastLogin ? ago(u.lastLogin.at) + ' · ' + u.lastLogin.ip : h('span', { class: 'muted' }, 'Not since restart')), + h('td', null, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')), + h('td', null, fmtDate(u.created)), + h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => editUser(u) }, 'Edit'))))); + drawUsers(us.users); + const reloadUsers = async () => drawUsers((await api('GET', '/users')).users); + const pwField = (id, label) => { + const input = h('input', { id, class: 'mono', value: newPassword(), autocomplete: 'off' }); + return { + input, + el: h('div', { class: 'field' }, h('label', { htmlFor: id }, label), + h('div', { class: 'row' }, input, + h('button', { type: 'button', class: 'btn', onClick: () => { input.value = newPassword(); } }, 'Generate'), + h('button', { type: 'button', class: 'btn', onClick: () => copy(input.value) }, 'Copy')), + h('span', { class: 'hint' }, 'Send it to the person yourself. At least 12 characters')), + }; + }; + const mustBox = (checked, hint) => { + const box = h('input', { type: 'checkbox', checked }); + return { box, el: h('label', { class: 'check' }, box, h('span', null, 'Must choose a new password at first sign-in', h('br'), h('span', { class: 'hint' }, hint))) }; + }; + const addUser = () => { + const nm = h('input', { id: 'nu', autocomplete: 'off', autocapitalize: 'none', required: true }); + const note = h('input', { id: 'nn', autocomplete: 'off' }); + const pw = pwField('np', 'Password'); + const must = mustBox(true, 'Untick it if you set a password the person keeps'); + const e = h('p', { class: 'err-text', role: 'alert' }); + dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { + ev.preventDefault(); + e.textContent = ''; + try { + await api('POST', '/users', { username: nm.value.trim(), note: note.value.trim(), password: pw.input.value, mustChangePassword: must.box.checked }); + close(); + toast('Added ' + nm.value.trim()); + reloadUsers(); + } catch (x) { e.textContent = x.message; } + } }, + h('h2', null, 'Add user'), + h('p', null, 'Every user is an admin and can change everything, including other users.'), + h('div', { class: 'grid' }, + h('div', { class: 'field' }, h('label', { htmlFor: 'nu' }, 'Username'), nm, h('span', { class: 'hint' }, 'Letters, numbers, . @ _ - · max 32')), + h('div', { class: 'field' }, h('label', { htmlFor: 'nn' }, 'Note'), note)), + pw.el, must.el, e, + h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Add user')))); + nm.focus(); + }; + const editUser = (u) => { + const nm = h('input', { id: 'eu', value: u.username, autocomplete: 'off', autocapitalize: 'none', required: true }); + const note = h('input', { id: 'en', value: u.note, autocomplete: 'off' }); + const must = mustBox(u.mustChangePassword, u.mustChangePassword ? 'Untick it to let them keep the password they have' : 'Tick it to make them choose a new one at the next sign-in'); + const e = h('p', { class: 'err-text', role: 'alert' }); + dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { + ev.preventDefault(); + e.textContent = ''; + try { + const body = {}; + if (nm.value.trim() !== u.username) body.username = nm.value.trim(); + if (note.value.trim() !== u.note) body.note = note.value.trim(); + if (!u.you && must.box.checked !== u.mustChangePassword) body.mustChangePassword = must.box.checked; + if (Object.keys(body).length) await api('PATCH', '/users/' + u.id, body); + close(); + if (u.you) { me = await api('GET', '/auth/me'); main = null; render(); } else reloadUsers(); + } catch (x) { e.textContent = x.message; } + } }, + h('h2', null, 'Edit ' + u.username), + h('div', { class: 'grid' }, + h('div', { class: 'field' }, h('label', { htmlFor: 'eu' }, 'Username'), nm), + h('div', { class: 'field' }, h('label', { htmlFor: 'en' }, 'Note'), note)), + u.you ? h('p', { class: 'hint' }, 'Change your own password under My account.') : [ + must.el, + h('div', { class: 'actions' }, + h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'), + h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…'))], + e, + h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save')))); + }; + const resetUser = (u) => { + const pw = pwField('rp', 'New password'); + const must = mustBox(true, 'Untick it if you set a password the person keeps'); + const e = h('p', { class: 'err-text', role: 'alert' }); + dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { + ev.preventDefault(); + e.textContent = ''; + try { + await api('POST', '/users/' + u.id + '/reset-password', { password: pw.input.value, mustChangePassword: must.box.checked }); + close(); + toast('Password reset for ' + u.username); + reloadUsers(); + } catch (x) { e.textContent = x.message; } + } }, + h('h2', null, 'Reset password for ' + u.username), + h('p', null, u.username + ' is signed out in every browser. Their app tokens keep working.'), + pw.el, must.el, e, + h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password')))); + }; + const deleteUser = async (u) => { + const tokens = u.tokens ? ' Their ' + (u.tokens === 1 ? 'app token is' : u.tokens + ' app tokens are') + ' revoked too.' : ''; + if (!await confirmDialog({ title: 'Delete ' + u.username + '?', text: u.username + ' is signed out and can no longer sign in.' + tokens, ok: 'Delete user', danger: true })) return; + try { await api('DELETE', '/users/' + u.id); toast('Deleted ' + u.username); reloadUsers(); reloadTokens(); } catch (x) { toast(x.message, true); } + }; + // web interface const web = JSON.parse(JSON.stringify(s.web)); const restartBox = h('div'); @@ -1244,11 +1404,12 @@ const tbody = h('tbody'); const drawTokens = (tokens) => tbody.replaceChildren(...(tokens.length ? tokens.map((t) => h('tr', null, h('td', null, t.name), + h('td', null, t.owner), h('td', null, h('span', { class: 'badge' }, t.scope === 'ro' ? 'Read only' : 'Full access')), h('td', null, fmtDate(t.created)), h('td', null, t.lastUsed ? ago(t.lastUsed.at) + ' · ' + t.lastUsed.ip : 'Not since restart'), h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn danger small', onClick: () => revoke(t) }, 'Revoke')))) - : [h('tr', null, h('td', { colspan: '5', class: 'muted' }, 'No tokens yet.'))])); + : [h('tr', null, h('td', { colspan: '6', class: 'muted' }, 'No tokens yet.'))])); drawTokens(tk.tokens); const reloadTokens = async () => drawTokens((await api('GET', '/tokens')).tokens); const revoke = async (t) => { @@ -1355,12 +1516,12 @@ } }); fill(wrap, - h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Web interface, API access for the iOS app, logs, data retention and backups')), + h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')), restartBox, h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' }, - h('h2', { id: 'acc' }, 'Admin account'), - h('p', { class: 'lead' }, 'One admin account. The iOS app signs in with an API token, not this password.'), + h('h2', { id: 'acc' }, 'My account'), + h('p', { class: 'lead' }, 'Changing your password signs you out in other browsers. Your app tokens keep working.'), h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), uname), h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Current password'), cur), @@ -1369,6 +1530,14 @@ accErr, h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn' }, 'Save account'))), + h('section', { class: 'card flush', 'aria-labelledby': 'usr' }, + h('div', { class: 'cardhead' }, + h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')), + h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')), + h('div', { class: 'tbl' }, h('table', null, + h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), + userBody))), + h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' }, h('h2', { id: 'web' }, 'Web interface'), h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'), @@ -1388,7 +1557,7 @@ h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')), h('button', { type: 'button', class: 'btn primary', onClick: newToken }, 'Pair iOS app')), h('div', { class: 'tbl section' }, h('table', { class: 'narrow' }, - h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), + h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), tbody))), h('section', { class: 'card', 'aria-labelledby': 'lg' }, diff --git a/auth.go b/auth.go index 9e97627..9690526 100644 --- a/auth.go +++ b/auth.go @@ -94,15 +94,21 @@ func hashToken(tok string) string { // --- sessions, token use and login throttling (in memory) --- type principal struct { - Name string // "admin" or the token name + Name string // the username, or the token name + UserID string // the user, or the token's owner Scope string // rw | ro TokenID string - IsAdmin bool + IsAdmin bool // a signed-in user, not an API token RemoteIP string + // MustChangePassword blocks everything but changing the password. + MustChangePassword bool } type session struct { - user string + userID string + // stamp is the user's password hash at sign-in: a changed or reset + // password ends every session started with the old one. + stamp string expires time.Time } @@ -122,6 +128,7 @@ type Auth struct { mu sync.Mutex sessions map[string]*session used map[string]tokenUse + logins map[string]tokenUse // last sign-in per user ID fails map[string]*failState } @@ -131,7 +138,7 @@ const ( ) func newAuth(s *Store) *Auth { - return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, fails: map[string]*failState{}} + return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}} } func cookieName() string { return appName + "_session" } @@ -149,11 +156,18 @@ func (a *Auth) Login(user, pw, ip string) (string, error) { a.mu.Unlock() cfg := a.store.Get() - if cfg.Admin.PasswordHash == "" { - return "", errors.New("no admin password is set; run: " + appName + " -passwd") + if !cfg.passwordSet() { + return "", errors.New("no password is set; run: " + appName + " passwd") } - okUser := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Admin.Username)) == 1 - okPw := verifyPassword(cfg.Admin.PasswordHash, pw) + // An unknown username costs as much time as a wrong password, so the + // answer time does not tell which usernames exist. + u := cfg.userByName(strings.TrimSpace(user)) + okUser := u != nil && u.PasswordHash != "" + hash := dummyHash() + if okUser { + hash = u.PasswordHash + } + okPw := verifyPassword(hash, pw) a.mu.Lock() defer a.mu.Unlock() @@ -170,9 +184,42 @@ func (a *Auth) Login(user, pw, ip string) (string, error) { return "", errors.New("wrong username or password") } delete(a.fails, ip) + a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip} + return a.newSessionLocked(cfg, u), nil +} + +// NewSession signs a user in again, e.g. after they changed their password. +func (a *Auth) NewSession(u *User) string { + cfg := a.store.Get() + a.mu.Lock() + defer a.mu.Unlock() + return a.newSessionLocked(cfg, u) +} + +func (a *Auth) newSessionLocked(cfg *Config, u *User) string { id := randomString(32) - a.sessions[id] = &session{user: cfg.Admin.Username, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour)} - return id, nil + a.sessions[id] = &session{userID: u.ID, stamp: u.PasswordHash, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour)} + return id +} + +// LastLogin returns when the user last signed in since the service started. +func (a *Auth) LastLogin(userID string) *tokenUse { + a.mu.Lock() + defer a.mu.Unlock() + if u, ok := a.logins[userID]; ok { + return &u + } + return nil +} + +var dummy struct { + once sync.Once + hash string +} + +func dummyHash() string { + dummy.once.Do(func() { dummy.hash, _ = hashPassword(randomString(16)) }) + return dummy.hash } func (a *Auth) Logout(id string) { @@ -181,13 +228,6 @@ func (a *Auth) Logout(id string) { a.mu.Unlock() } -// DropSessions signs everyone out, e.g. after a password change. -func (a *Auth) DropSessions() { - a.mu.Lock() - a.sessions = map[string]*session{} - a.mu.Unlock() -} - func remoteIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { @@ -216,7 +256,7 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) { a.mu.Lock() a.used[t.ID] = tokenUse{At: time.Now(), IP: ip} a.mu.Unlock() - return &principal{Name: t.Name, Scope: t.Scope, TokenID: t.ID, RemoteIP: ip}, true + return &principal{Name: t.Name, UserID: t.UserID, Scope: t.Scope, TokenID: t.ID, RemoteIP: ip}, true } } return nil, false @@ -225,6 +265,7 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) { if err != nil { return nil, false } + cfg := a.store.Get() a.mu.Lock() defer a.mu.Unlock() s := a.sessions[c.Value] @@ -232,7 +273,14 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) { delete(a.sessions, c.Value) return nil, false } - return &principal{Name: s.user, Scope: "rw", IsAdmin: true, RemoteIP: ip}, true + // The user is looked up on every request: a deleted user or a changed + // password ends the session at once. + _, u := cfg.userByID(s.userID) + if u == nil || u.PasswordHash != s.stamp { + delete(a.sessions, c.Value) + return nil, false + } + return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword}, true } func (a *Auth) TokenUse(id string) *tokenUse { diff --git a/config.go b/config.go index 2344731..1bdca66 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "encoding/json" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "sync" "syscall" @@ -19,14 +21,17 @@ import ( // config.json and is the single source of truth: the kernel (interface, peers, // firewall) is reconciled to match it. type Config struct { - Version int `json:"version"` - Web WebConfig `json:"web"` - Admin Admin `json:"admin"` - APITokens []APIToken `json:"apiTokens"` - Server Server `json:"server"` - Peers []Peer `json:"peers"` - Log LogConfig `json:"log"` - Stats StatsConfig `json:"stats"` + Version int `json:"version"` + Web WebConfig `json:"web"` + Users []User `json:"users"` + APITokens []APIToken `json:"apiTokens"` + // Admin is the single account of config version 1; applyDefaults moves + // it into Users. + Admin *Admin `json:"admin,omitempty"` + Server Server `json:"server"` + Peers []Peer `json:"peers"` + Log LogConfig `json:"log"` + Stats StatsConfig `json:"stats"` } // StatsConfig sets how long traffic history is kept in stats.json. @@ -69,11 +74,24 @@ type Admin struct { PasswordHash string `json:"passwordHash"` } +// User is an account for the web interface. Every user is an admin. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Note string `json:"note,omitempty"` + PasswordHash string `json:"passwordHash"` + // MustChangePassword is set when an admin chose a temporary password: + // the user can do nothing else until they pick their own. + MustChangePassword bool `json:"mustChangePassword,omitempty"` + Created time.Time `json:"created"` +} + type APIToken struct { ID string `json:"id"` Name string `json:"name"` Hash string `json:"hash"` - Scope string `json:"scope"` // rw | ro + Scope string `json:"scope"` // rw | ro + UserID string `json:"userId"` // the user who created it Created time.Time `json:"created"` } @@ -136,12 +154,12 @@ type LogConfig struct { MaxFiles int `json:"maxFiles"` } -const configVersion = 1 +const configVersion = 2 // applyDefaults fills zero values. It never overwrites values that are set, // so a minimal hand-written config.json grows into a complete one. func (c *Config) applyDefaults() { - if c.Version == 0 { + if c.Version < configVersion { c.Version = configVersion } if c.Web.Listen == "" { @@ -160,8 +178,19 @@ func (c *Config) applyDefaults() { if c.Web.SessionHours == 0 { c.Web.SessionHours = 12 } - if c.Admin.Username == "" { - c.Admin.Username = "admin" + if len(c.Users) == 0 { + u := User{ID: newID(), Username: "admin", Created: time.Now().UTC()} + if c.Admin != nil { + u.Username = cmp.Or(c.Admin.Username, "admin") + u.PasswordHash = c.Admin.PasswordHash + } + c.Users = []User{u} + } + c.Admin = nil + for i := range c.APITokens { + if c.APITokens[i].UserID == "" { + c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed + } } s := &c.Server if s.Interface == "" { @@ -237,6 +266,13 @@ func (c *Config) initServer() (bool, error) { var peerNameRe = regexp.MustCompile(`^[a-zA-Z0-9.@_-]{1,32}$`) +func validateUsername(name string) error { + if !peerNameRe.MatchString(name) || strings.HasPrefix(name, "-") || strings.HasPrefix(name, ".") { + return errors.New("username must be 1–32 characters: letters, digits and . @ _ -, not starting with - or .") + } + return nil +} + func validatePeerName(name string) error { switch { case !peerNameRe.MatchString(name): @@ -334,6 +370,33 @@ func (c *Config) validate() error { return fmt.Errorf("unknown tls.mode %q", c.Web.TLS.Mode) } + if len(c.Users) == 0 { + return errors.New("at least one user is required") + } + userIDs := map[string]bool{} + usernames := map[string]bool{} + for _, u := range c.Users { + if err := validateUsername(u.Username); err != nil { + return fmt.Errorf("user %q: %w", u.Username, err) + } + if usernames[strings.ToLower(u.Username)] { + return fmt.Errorf("username %q is used twice", u.Username) + } + if u.ID == "" || userIDs[u.ID] { + return fmt.Errorf("user %q: missing or duplicate id", u.Username) + } + if len(u.Note) > 200 { + return fmt.Errorf("user %q: note must be at most 200 characters", u.Username) + } + usernames[strings.ToLower(u.Username)] = true + userIDs[u.ID] = true + } + for _, t := range c.APITokens { + if !userIDs[t.UserID] { + return fmt.Errorf("API token %q belongs to no user", t.Name) + } + } + names := map[string]bool{} ips := map[netip.Addr]bool{} keys := map[string]bool{} @@ -376,6 +439,30 @@ func (c *Config) validate() error { return nil } +func (c *Config) userByID(id string) (int, *User) { + for i := range c.Users { + if c.Users[i].ID == id { + return i, &c.Users[i] + } + } + return -1, nil +} + +// userByName finds a user regardless of letter case. +func (c *Config) userByName(name string) *User { + for i := range c.Users { + if strings.EqualFold(c.Users[i].Username, name) { + return &c.Users[i] + } + } + return nil +} + +// passwordSet reports whether anyone can sign in yet. +func (c *Config) passwordSet() bool { + return slices.ContainsFunc(c.Users, func(u User) bool { return u.PasswordHash != "" }) +} + func (c *Config) peerByID(id string) (int, *Peer) { for i := range c.Peers { if c.Peers[i].ID == id { diff --git a/installask.go b/installask.go index 93ebbc3..1cfbb55 100644 --- a/installask.go +++ b/installask.go @@ -106,7 +106,7 @@ func (p installPlan) apply(c *Config) { c.Server.ListenPort = p.port } if p.passwordHash != "" { - c.Admin.PasswordHash = p.passwordHash + c.Users[0].PasswordHash = p.passwordHash } } @@ -287,11 +287,11 @@ func askInstall(in io.Reader, cur *Config, existing bool, given map[string]bool, } } - // Admin account, only when no password is set yet. - if cur.Admin.PasswordHash == "" { + // First user, only when nobody has a password yet. + if !cur.passwordSet() { fmt.Println("\nAdmin account") for { - pw, err := readSecret(fmt.Sprintf(" Password for %q (at least 12 characters): ", cur.Admin.Username)) + pw, err := readSecret(fmt.Sprintf(" Password for %q (at least 12 characters): ", cur.Users[0].Username)) if err != nil { return p, errCancelled } @@ -393,6 +393,6 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) { fmt.Printf(" Tunnel network %s\n", tunnel) fmt.Printf(" Firewall %s must be reachable\n", strings.Join(ports, ", ")) if p.passwordHash != "" { - fmt.Printf(" Admin %s (password set)\n", next.Admin.Username) + fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username) } } diff --git a/main.go b/main.go index de1afd6..419dbce 100644 --- a/main.go +++ b/main.go @@ -53,7 +53,7 @@ func main() { flag.Usage = usage configPath := flag.String("config", defaultConfigPath(), "path to config.json; logs and stats are kept next to it") - passwd := flag.Bool("passwd", false, "set the admin password and exit") + passwd := flag.Bool("passwd", false, "set a user's password and exit (username as argument; default: the first user)") down := flag.Bool("down", false, "remove the WireGuard interface and firewall rules and exit") check := flag.Bool("check", false, "check that config.json is valid for this version and exit") showVersion := flag.Bool("version", false, "print the version and exit") @@ -66,7 +66,7 @@ func main() { var err error switch { case *passwd: - if err = setPassword(*configPath); err == nil { + if err = setPassword(*configPath, flag.Arg(0)); err == nil { fmt.Fprintf(os.Stderr, "If the service is running: systemctl reload %s\n", serviceName) } case *down: @@ -106,12 +106,25 @@ func readSecret(prompt string) (string, error) { return strings.TrimRight(line, "\r\n"), err } -func setPassword(path string) error { +// setPassword sets a user's password from the terminal; it is the way back +// in for someone locked out. An empty username means the first user. +func setPassword(path, username string) error { store, err := openStore(path) if err != nil { return err } - pw, err := readSecret("New admin password: ") + cfg := store.Get() + u := &cfg.Users[0] + if username != "" { + if u = cfg.userByName(username); u == nil { + names := make([]string, len(cfg.Users)) + for i, x := range cfg.Users { + names[i] = x.Username + } + return fmt.Errorf("no user %q; users: %s", username, strings.Join(names, ", ")) + } + } + pw, err := readSecret(fmt.Sprintf("New password for %q: ", u.Username)) if err != nil { return err } @@ -131,10 +144,15 @@ func setPassword(path string) error { if err != nil { return err } - if err := store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; return nil }); err != nil { + id := u.ID + if err := store.Update(func(c *Config) error { + _, u := c.userByID(id) + u.PasswordHash, u.MustChangePassword = hash, false + return nil + }); err != nil { return err } - fmt.Fprintf(os.Stderr, "Password for %q saved.\n", store.Get().Admin.Username) + fmt.Fprintf(os.Stderr, "Password for %q saved.\n", u.Username) return nil } @@ -167,9 +185,9 @@ func run(configPath string) error { } defer logw.Close() slog.Info("starting", "version", version, "config", configPath) - if cfg.Admin.PasswordHash == "" { - slog.Warn("no admin password set; run: sudo " + installBin + " passwd") - fmt.Fprintf(os.Stderr, "No admin password set. Run: sudo %s passwd\n", installBin) + if !cfg.passwordSet() { + slog.Warn("no password set; run: sudo " + installBin + " passwd") + fmt.Fprintf(os.Stderr, "No password set. Run: sudo %s passwd\n", installBin) } kernel, err := newKernel() diff --git a/main_test.go b/main_test.go index c34339c..d452412 100644 --- a/main_test.go +++ b/main_test.go @@ -218,7 +218,11 @@ func TestAPI(t *testing.T) { t.Fatal(err) } hash, _ := hashPassword("a long test password") - _ = store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; c.Server.Endpoint = "vpn.example.net"; return nil }) + _ = store.Update(func(c *Config) error { + c.Users[0].PasswordHash = hash + c.Server.Endpoint = "vpn.example.net" + return nil + }) k := &fakeKernel{} st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), @@ -521,7 +525,11 @@ func TestSetupLink(t *testing.T) { t.Fatal(err) } hash, _ := hashPassword("a long test password") - _ = store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; c.Server.Endpoint = "vpn.example.net"; return nil }) + _ = store.Update(func(c *Config) error { + c.Users[0].PasswordHash = hash + c.Server.Endpoint = "vpn.example.net" + return nil + }) k := &fakeKernel{} st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), @@ -634,7 +642,7 @@ func TestInstallQuestions(t *testing.T) { fresh := func() *Config { c := &Config{} c.applyDefaults() - c.Admin.PasswordHash = hash // skips the password question + c.Users[0].PasswordHash = hash // skips the password question return c } @@ -738,3 +746,128 @@ func TestLatency(t *testing.T) { t.Fatalf("history %+v", last) } } + +// TestConfigMigration turns a version 1 config with one admin into users. +func TestConfigMigration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + hash, _ := hashPassword("a long test password") + old := `{"version":1,"admin":{"username":"dan","passwordHash":"` + hash + `"},` + + `"apiTokens":[{"id":"t1","name":"iPhone","hash":"sha256:x","scope":"rw"}]}` + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + store, err := openStore(path) + if err != nil { + t.Fatal(err) + } + c := store.Get() + if c.Version != 2 || c.Admin != nil || len(c.Users) != 1 || c.Users[0].Username != "dan" || c.Users[0].PasswordHash != hash { + t.Fatalf("not migrated: %+v", c.Users) + } + if c.APITokens[0].UserID != c.Users[0].ID { + t.Fatal("token not given to the migrated user") + } + raw, _ := os.ReadFile(path) + if bytes.Contains(raw, []byte(`"admin":`)) { + t.Fatal("old admin block still saved") + } +} + +// TestUsers covers adding, the forced password change, resets, renames and +// deleting users over HTTP. +func TestUsers(t *testing.T) { + dir := t.TempDir() + store, err := openStore(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + hash, _ := hashPassword("a long test password") + _ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil }) + k := &fakeKernel{} + st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) + app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), + tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}} + srv := httptest.NewServer(app.routes()) + defer srv.Close() + + // client returns a call function with its own cookie jar. + client := func() func(method, path string, body any, want int) map[string]any { + jar, _ := cookiejar.New(nil) + cl := &http.Client{Jar: jar} + return func(method, path string, body any, want int) map[string]any { + t.Helper() + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd) + req.Header.Set("Content-Type", "application/json") + resp, err := cl.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + if resp.StatusCode != want { + t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out) + } + return out + } + } + admin := client() + admin("POST", "/auth/login", map[string]string{"username": "ADMIN", "password": "a long test password"}, 200) // any letter case + me := admin("GET", "/auth/me", nil, 200) + myID := me["id"].(string) + + // A new user with a temporary password can only change it. + eve := admin("POST", "/users", map[string]any{"username": "eve", "password": "temporary password 1"}, 201)["user"].(map[string]any) + eveID := eve["id"].(string) + if eve["mustChangePassword"] != true { + t.Fatal("mustChangePassword should default to true") + } + admin("POST", "/users", map[string]any{"username": "Eve", "password": "temporary password 1"}, 400) // taken, any case + admin("POST", "/users", map[string]any{"username": "short", "password": "short"}, 400) + + e := client() + e("POST", "/auth/login", map[string]string{"username": "eve", "password": "temporary password 1"}, 200) + if e("GET", "/auth/me", nil, 200)["mustChangePassword"] != true { + t.Fatal("me should report the forced change") + } + e("GET", "/peers", nil, 403) + e("POST", "/auth/password", map[string]string{"current": "temporary password 1", "new": "temporary password 1"}, 400) + e("POST", "/auth/password", map[string]string{"current": "temporary password 1", "new": "eve's own password"}, 200) + e("GET", "/peers", nil, 200) // the session continues after the change + + // A user created without the flag can work at once. + sam := admin("POST", "/users", map[string]any{"username": "sam", "password": "sam's password 123", "mustChangePassword": false}, 201)["user"].(map[string]any) + s := client() + s("POST", "/auth/login", map[string]string{"username": "sam", "password": "sam's password 123"}, 200) + s("GET", "/peers", nil, 200) + + // A reset ends the user's sessions; the flag can be cleared later. + admin("POST", "/users/"+eveID+"/reset-password", map[string]any{"password": "another temp pw 1"}, 200) + e("GET", "/peers", nil, 401) + admin("PATCH", "/users/"+eveID, map[string]any{"mustChangePassword": false, "username": "eve2"}, 200) + e("POST", "/auth/login", map[string]string{"username": "eve2", "password": "another temp pw 1"}, 200) + e("GET", "/peers", nil, 200) + admin("POST", "/users/"+myID+"/reset-password", map[string]any{"password": "whatever password"}, 400) // own: use /auth/password + + // Deleting a user removes their tokens and ends their sessions. + tok := s("POST", "/tokens", map[string]string{"name": "sam's phone", "scope": "rw"}, 201) + if l := admin("GET", "/tokens", nil, 200)["tokens"].([]any); l[0].(map[string]any)["owner"] != "sam" { + t.Fatalf("token owner: %v", l) + } + admin("DELETE", "/users/"+myID, nil, 400) + admin("DELETE", "/users/"+sam["id"].(string), nil, 200) + s("GET", "/peers", nil, 401) + if len(store.Get().APITokens) != 0 { + t.Fatalf("token of deleted user kept: %v", tok["name"]) + } + 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) +} diff --git a/setup.go b/setup.go index 5415982..1fe915b 100644 --- a/setup.go +++ b/setup.go @@ -43,8 +43,8 @@ Usage (as root): replace the installed binary with this one and restart %s uninstall [-purge] [-y] remove the service, interface and firewall table (-purge also deletes %s) - %s passwd - set the admin password of the installed service + %s passwd [username] + set a user's password (default: the first user) of the installed service %s version Without a command it runs the service: @@ -433,9 +433,9 @@ func cmdInstall(args []string) error { return err } - if store.Get().Admin.PasswordHash == "" { - fmt.Println("\nChoose the admin password for the web interface (user \"admin\", at least 12 characters).") - if err := setPassword(configFile); err != nil { + if cfg := store.Get(); !cfg.passwordSet() { + fmt.Printf("\nChoose the password for the web interface (user %q, at least 12 characters).\n", cfg.Users[0].Username) + if err := setPassword(configFile, ""); err != nil { return err } } @@ -571,6 +571,11 @@ func cmdUpdate(args []string) error { if rErr := copyFile(oldBin, installBin, 0o755, uid, gid); rErr != nil { return fmt.Errorf("update failed and restoring the old binary failed too: %v (original error: %w)", rErr, err) } + // The new version may have upgraded config.json to a format the old + // one cannot read. + if rErr := copyFile(backup, configFile, 0o600, uid, gid); rErr != nil { + return fmt.Errorf("update failed and restoring %s failed too: %v (original error: %w)", configFile, rErr, err) + } if rErr := restartAndVerify(); rErr != nil { return fmt.Errorf("update failed and the old version does not start either: %v (original error: %w)", rErr, err) } @@ -649,7 +654,7 @@ func cmdPasswd(args []string) error { if os.Geteuid() != 0 && runtime.GOOS == "linux" { return errors.New("run as root, e.g. with sudo") } - if err := setPassword(*path); err != nil { + if err := setPassword(*path, fs.Arg(0)); err != nil { return err } if *path == configFile && shOut("systemctl", "is-active", serviceName) == "active" { diff --git a/users.go b/users.go new file mode 100644 index 0000000..f2f5581 --- /dev/null +++ b/users.go @@ -0,0 +1,210 @@ +package main + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "time" +) + +// Users of the web interface. Every user is an admin; the only rules are +// that nobody deletes themselves (so one user always remains) and that +// everyone changes their own password with the current one. + +type userView struct { + ID string `json:"id"` + Username string `json:"username"` + Note string `json:"note"` + MustChangePassword bool `json:"mustChangePassword"` + Created time.Time `json:"created"` + LastLogin *tokenUse `json:"lastLogin"` // since the service started + Tokens int `json:"tokens"` + You bool `json:"you"` +} + +func (a *App) userView(c *Config, u *User, me string) userView { + n := 0 + for _, t := range c.APITokens { + if t.UserID == u.ID { + n++ + } + } + return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me} +} + +// username names a user for lists, or "" if the ID is unknown. +func (a *App) username(c *Config, id string) string { + if _, u := c.userByID(id); u != nil { + return u.Username + } + return "" +} + +func (a *App) listUsers(w http.ResponseWriter, r *http.Request) { + cfg := a.store.Get() + out := make([]userView, 0, len(cfg.Users)) + for i := range cfg.Users { + out = append(out, a.userView(cfg, &cfg.Users[i], who(r).UserID)) + } + writeJSON(w, http.StatusOK, map[string]any{"users": out}) +} + +// newPasswordHash checks and hashes a password an admin chose for someone. +func newPasswordHash(pw string) (string, error) { + if err := validatePassword(pw); err != nil { + return "", err + } + return hashPassword(pw) +} + +func (a *App) createUser(w http.ResponseWriter, r *http.Request) { + var in struct { + Username string `json:"username"` + Note string `json:"note"` + Password string `json:"password"` + MustChangePassword *bool `json:"mustChangePassword"` // default true + } + if err := readJSON(r, &in); err != nil { + writeErr(w, err) + return + } + hash, err := newPasswordHash(in.Password) + if err != nil { + writeErr(w, err) + return + } + u := User{ + ID: newID(), Username: strings.TrimSpace(in.Username), Note: strings.TrimSpace(in.Note), + PasswordHash: hash, MustChangePassword: in.MustChangePassword == nil || *in.MustChangePassword, + Created: time.Now().UTC(), + } + if err := a.store.Update(func(c *Config) error { + if c.userByName(u.Username) != nil { + return badRequest("username %q is taken", u.Username) + } + c.Users = append(c.Users, u) + return nil + }); err != nil { + writeErr(w, err) + return + } + a.audit(r, "user created", "user", u.Username, "mustChangePassword", u.MustChangePassword) + cfg := a.store.Get() + _, saved := cfg.userByID(u.ID) + writeJSON(w, http.StatusCreated, map[string]any{"user": a.userView(cfg, saved, who(r).UserID)}) +} + +// patchUser renames a user, changes the note, or sets or clears the +// "must change password" flag. +func (a *App) patchUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + m, err := decodeFields(r) + if err != nil { + writeErr(w, err) + return + } + var name string + var changed []string + err = a.store.Update(func(c *Config) error { + _, u := c.userByID(id) + if u == nil { + return badRequest("no such user") + } + for _, f := range []struct { + key string + dst any + }{ + {"username", &u.Username}, {"note", &u.Note}, {"mustChangePassword", &u.MustChangePassword}, + } { + if raw, ok := m[f.key]; ok { + if err := json.Unmarshal(raw, f.dst); err != nil { + return badRequest("%s: %v", f.key, err) + } + changed = append(changed, f.key) + } + } + u.Username = strings.TrimSpace(u.Username) + u.Note = strings.TrimSpace(u.Note) + if other := c.userByName(u.Username); other != nil && other.ID != u.ID { + return badRequest("username %q is taken", u.Username) + } + name = u.Username + return nil + }) + if err != nil { + writeErr(w, err) + return + } + a.audit(r, "user updated", "user", name, "fields", changed) + cfg := a.store.Get() + _, u := cfg.userByID(id) + writeJSON(w, http.StatusOK, map[string]any{"user": a.userView(cfg, u, who(r).UserID)}) +} + +// resetPassword sets a password for another user. Their sessions end, +// because sessions are tied to the password they started with. +func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == who(r).UserID { + writeErr(w, badRequest("change your own password under My account")) + return + } + var in struct { + Password string `json:"password"` + MustChangePassword *bool `json:"mustChangePassword"` // default true + } + if err := readJSON(r, &in); err != nil { + writeErr(w, err) + return + } + hash, err := newPasswordHash(in.Password) + if err != nil { + writeErr(w, err) + return + } + must := in.MustChangePassword == nil || *in.MustChangePassword + var name string + if err := a.store.Update(func(c *Config) error { + _, u := c.userByID(id) + if u == nil { + return badRequest("no such user") + } + u.PasswordHash, u.MustChangePassword = hash, must + name = u.Username + return nil + }); err != nil { + writeErr(w, err) + return + } + a.audit(r, "user password reset", "user", name, "mustChangePassword", must) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// deleteUser removes a user and their API tokens. +func (a *App) deleteUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == who(r).UserID { + writeErr(w, badRequest("you cannot delete yourself")) + return + } + var name string + var tokens int + if err := a.store.Update(func(c *Config) error { + i, u := c.userByID(id) + if u == nil { + return badRequest("no such user") + } + name = u.Username + n := len(c.APITokens) + c.APITokens = slices.DeleteFunc(c.APITokens, func(t APIToken) bool { return t.UserID == id }) + tokens = n - len(c.APITokens) + c.Users = slices.Delete(c.Users, i, i+1) + return nil + }); err != nil { + writeErr(w, err) + return + } + a.audit(r, "user deleted", "user", name, "tokensRevoked", tokens) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +}