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.
This commit is contained in:
Daniel Redetzke
2026-10-04 15:51:14 +03:00
parent f703618b9d
commit 4ecfddf06a
11 changed files with 825 additions and 109 deletions
+20 -12
View File
@@ -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 - **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 `CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
`/opt/ghostwire`. `/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 After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
HttpOnly, SameSite=Strict cookie and last 12 hours by default. 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. - **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 endpoint or port means existing devices need a new config, the summary says
how many. how many.
Then open `https://vpn.example.net` and sign in as `admin`. Root is needed only Then open `https://vpn.example.net` and sign in as `admin`. Add more users
for the commands below, never for the running service. under Settings → Users. Root is needed only for the commands below, never for
the running service.
## Commands (as root) ## Commands (as root)
| Command | What it does | | 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 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-<old version>`, 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-<old version>`, 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 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. | | `GHOSTWIRE version` | Prints the version. |
Updating restarts only the management service. VPN connections stay up, 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 ## API
Base path `/api/v1`. The web interface signs in with a session cookie. Apps and Base path `/api/v1`. The web interface signs in with a session cookie; every
scripts use `Authorization: Bearer <token>`; create the token under Settings → user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
Pair iOS app. A read-only token may only use GET. Full-access tokens can do the token under Settings → Pair iOS app. A token belongs to the user who made
everything the web interface does except the admin-only endpoints: password, it and is revoked when that user is deleted. A read-only token may only use
API tokens, backup and restore, and changing the admin username. 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 /status GET /stats?range=24h|7d|30d|90d
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
GET /peers POST /peers (returns the config and QR once) 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 /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart GET /settings PATCH /settings POST /restart
GET /logs?level=&limit=&audit=1 GET /logs/download 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) public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
``` ```
+54 -23
View File
@@ -77,7 +77,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
return return
} }
if adminOnly && !p.IsAdmin { 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 return
} }
if p.Scope == "ro" && r.Method != http.MethodGet { 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) mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
g("GET /api/v1/auth/me", a.me) g("GET /api/v1/auth/me", a.me)
adm("POST /api/v1/auth/password", a.changePassword) 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/status", a.status)
g("GET /api/v1/stats", a.allStats) 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) mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings and read logs. // 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("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings) g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/restart", a.restart) 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()}) writeJSON(w, code, map[string]string{"error": err.Error()})
return return
} }
cfg := a.store.Get() a.setSessionCookie(w, r, id)
http.SetCookie(w, &http.Cookie{
Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode, MaxAge: cfg.Web.SessionHours * 3600,
})
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip) slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) 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) { func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName()); err == nil { if c, err := r.Cookie(cookieName()); err == nil {
a.auth.Logout(c.Value) 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) { func (a *App) me(w http.ResponseWriter, r *http.Request) {
p := who(r) 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) { func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
var in struct{ Current, New string } var in struct{ Current, New string }
if err := readJSON(r, &in); err != nil { if err := readJSON(r, &in); err != nil {
writeErr(w, err) writeErr(w, err)
return 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")) writeErr(w, badRequest("current password is wrong"))
return 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 { if err := validatePassword(in.New); err != nil {
writeErr(w, err) writeErr(w, err)
return return
@@ -231,12 +254,24 @@ func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return 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) writeErr(w, err)
return 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.audit(r, "password changed")
a.auth.DropSessions()
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) 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, "log": cfg.Log,
"stats": cfg.Stats, "stats": cfg.Stats,
"geo": a.geoStatus(), "geo": a.geoStatus(),
"adminUsername": cfg.Admin.Username, "adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"fingerprint": a.tls.Fingerprint(), "fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath, "logPath": a.logPath,
}) })
@@ -933,18 +968,12 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return return
} }
if _, ok := m["adminUsername"]; ok && !who(r).IsAdmin { if _, ok := m["adminUsername"]; ok {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the admin account can change the username"}) writeErr(w, badRequest("usernames are changed under /users"))
return return
} }
var restart bool var restart bool
err = a.store.Update(func(c *Config) error { 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) before, _ := json.Marshal(c.Web)
if err := field(m, "web", &c.Web); err != nil { if err := field(m, "web", &c.Web); err != nil {
return err return err
@@ -978,14 +1007,16 @@ type tokenView struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Scope string `json:"scope"` Scope string `json:"scope"`
Owner string `json:"owner"` // username
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastUsed *tokenUse `json:"lastUsed"` LastUsed *tokenUse `json:"lastUsed"`
} }
func (a *App) listTokens(w http.ResponseWriter, r *http.Request) { func (a *App) listTokens(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
out := []tokenView{} out := []tokenView{}
for _, t := range a.store.Get().APITokens { for _, t := range cfg.APITokens {
out = append(out, tokenView{t.ID, t.Name, t.Scope, t.Created, a.auth.TokenUse(t.ID)}) 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}) 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" in.Scope = "rw"
} }
secret := tokenPrefix + randomString(32) 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 { if err := a.store.Update(func(c *Config) error { c.APITokens = append(c.APITokens, t); return nil }); err != nil {
writeErr(w, err) writeErr(w, err)
return return
+7
View File
@@ -108,6 +108,8 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
.dot.bad { background: var(--bad); } .dot.bad { background: var(--bad); }
.dot.warn { background: var(--up); } .dot.warn { background: var(--up); }
.dot.big { width: 10px; height: 10px; } .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; } .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 { 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); } .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) .en { display: none; }
.signin:hover:not(:disabled) .ja { display: inline; } .signin:hover:not(:disabled) .ja { display: inline; }
.loginform .err-text { color: #ff8a80; } .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; } .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; } .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; } .err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
+188 -19
View File
@@ -106,6 +106,14 @@
} }
const list = (s) => s.split(',').map((x) => x.trim()).filter(Boolean); 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); const sameList = (a, b) => JSON.stringify(a || null) === JSON.stringify(b || null);
function peerState(p) { function peerState(p) {
@@ -212,6 +220,10 @@
showLogin(); showLogin();
throw new Error('Signed out'); 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); if (!r.ok) throw new Error(data.error || r.statusText);
return data; return data;
} }
@@ -512,6 +524,7 @@
if (!me) { if (!me) {
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; } try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
} }
if (me.mustChangePassword) { showNewPassword(); return; }
if (!main || !main.isConnected) buildShell(); if (!main || !main.isConnected) buildShell();
every(30000, refreshSide); every(30000, refreshSide);
const hash = location.hash || '#/'; const hash = location.hash || '#/';
@@ -540,7 +553,7 @@
cleanups = []; cleanups = [];
main = null; main = null;
const err = h('p', { class: 'err-text', role: 'alert' }); 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 }); 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". // On hover the label turns into its Japanese reading; screen readers keep "Sign in".
const btn = h('button', { type: 'submit', class: 'btn primary signin' }, const btn = h('button', { type: 'submit', class: 'btn primary signin' },
@@ -552,7 +565,7 @@
try { try {
await api('POST', '/auth/login', { username: user.value, password: pw.value }); await api('POST', '/auth/login', { username: user.value, password: pw.value });
me = await api('GET', '/auth/me'); me = await api('GET', '/auth/me');
render(); if (me.mustChangePassword) showNewPassword(pw.value); else render();
} catch (x) { } catch (x) {
err.textContent = x.message; err.textContent = x.message;
btn.disabled = false; btn.disabled = false;
@@ -566,7 +579,47 @@
brand(72), brand(72),
form), form),
h('p', { class: 'loginfoot' }, 'WireGuard server manager'))); 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 ---------- // ---------- dashboard ----------
@@ -1177,14 +1230,14 @@
async function viewSettings(wrap) { async function viewSettings(wrap) {
if (!me.isAdmin) { 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; 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'; let logLevelFilter = 'all';
// admin account // my account
const uname = h('input', { id: 'u', value: s.adminUsername, autocomplete: 'username' }); 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 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 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' }); const p2 = h('input', { id: 'p2', type: 'password', autocomplete: 'new-password' });
@@ -1193,20 +1246,127 @@
e.preventDefault(); e.preventDefault();
accErr.textContent = ''; accErr.textContent = '';
try { 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 || cur.value) {
if (p1.value !== p2.value) throw new Error('The new passwords do not match'); 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 }); await api('POST', '/auth/password', { current: cur.value, new: p1.value });
toast('Password changed. Please sign in again.'); toast('Password changed. Other browsers are signed out.');
me = null;
showLogin();
} }
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; } } 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 // web interface
const web = JSON.parse(JSON.stringify(s.web)); const web = JSON.parse(JSON.stringify(s.web));
const restartBox = h('div'); const restartBox = h('div');
@@ -1244,11 +1404,12 @@
const tbody = h('tbody'); const tbody = h('tbody');
const drawTokens = (tokens) => tbody.replaceChildren(...(tokens.length ? tokens.map((t) => h('tr', null, const drawTokens = (tokens) => tbody.replaceChildren(...(tokens.length ? tokens.map((t) => h('tr', null,
h('td', null, t.name), 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, h('span', { class: 'badge' }, t.scope === 'ro' ? 'Read only' : 'Full access')),
h('td', null, fmtDate(t.created)), h('td', null, fmtDate(t.created)),
h('td', null, t.lastUsed ? ago(t.lastUsed.at) + ' · ' + t.lastUsed.ip : 'Not since restart'), 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('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); drawTokens(tk.tokens);
const reloadTokens = async () => drawTokens((await api('GET', '/tokens')).tokens); const reloadTokens = async () => drawTokens((await api('GET', '/tokens')).tokens);
const revoke = async (t) => { const revoke = async (t) => {
@@ -1355,12 +1516,12 @@
} }); } });
fill(wrap, 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, restartBox,
h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' }, h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' },
h('h2', { id: 'acc' }, 'Admin account'), h('h2', { id: 'acc' }, 'My account'),
h('p', { class: 'lead' }, 'One admin account. The iOS app signs in with an API token, not this password.'), 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: 'grid' },
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), uname), h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), uname),
h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Current password'), cur), h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Current password'), cur),
@@ -1369,6 +1530,14 @@
accErr, accErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn' }, 'Save account'))), 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('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
h('h2', { id: 'web' }, 'Web interface'), h('h2', { id: 'web' }, 'Web interface'),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'), 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('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('button', { type: 'button', class: 'btn primary', onClick: newToken }, 'Pair iOS app')),
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' }, 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))), tbody))),
h('section', { class: 'card', 'aria-labelledby': 'lg' }, h('section', { class: 'card', 'aria-labelledby': 'lg' },
+67 -19
View File
@@ -94,15 +94,21 @@ func hashToken(tok string) string {
// --- sessions, token use and login throttling (in memory) --- // --- sessions, token use and login throttling (in memory) ---
type principal struct { 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 Scope string // rw | ro
TokenID string TokenID string
IsAdmin bool IsAdmin bool // a signed-in user, not an API token
RemoteIP string RemoteIP string
// MustChangePassword blocks everything but changing the password.
MustChangePassword bool
} }
type session struct { 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 expires time.Time
} }
@@ -122,6 +128,7 @@ type Auth struct {
mu sync.Mutex mu sync.Mutex
sessions map[string]*session sessions map[string]*session
used map[string]tokenUse used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState fails map[string]*failState
} }
@@ -131,7 +138,7 @@ const (
) )
func newAuth(s *Store) *Auth { 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" } func cookieName() string { return appName + "_session" }
@@ -149,11 +156,18 @@ func (a *Auth) Login(user, pw, ip string) (string, error) {
a.mu.Unlock() a.mu.Unlock()
cfg := a.store.Get() cfg := a.store.Get()
if cfg.Admin.PasswordHash == "" { if !cfg.passwordSet() {
return "", errors.New("no admin password is set; run: " + appName + " -passwd") return "", errors.New("no password is set; run: " + appName + " passwd")
} }
okUser := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Admin.Username)) == 1 // An unknown username costs as much time as a wrong password, so the
okPw := verifyPassword(cfg.Admin.PasswordHash, pw) // 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() a.mu.Lock()
defer a.mu.Unlock() 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") return "", errors.New("wrong username or password")
} }
delete(a.fails, ip) 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) id := randomString(32)
a.sessions[id] = &session{user: cfg.Admin.Username, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour)} a.sessions[id] = &session{userID: u.ID, stamp: u.PasswordHash, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour)}
return id, nil 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) { func (a *Auth) Logout(id string) {
@@ -181,13 +228,6 @@ func (a *Auth) Logout(id string) {
a.mu.Unlock() 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 { func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
@@ -216,7 +256,7 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
a.mu.Lock() a.mu.Lock()
a.used[t.ID] = tokenUse{At: time.Now(), IP: ip} a.used[t.ID] = tokenUse{At: time.Now(), IP: ip}
a.mu.Unlock() 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 return nil, false
@@ -225,6 +265,7 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
if err != nil { if err != nil {
return nil, false return nil, false
} }
cfg := a.store.Get()
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
s := a.sessions[c.Value] s := a.sessions[c.Value]
@@ -232,7 +273,14 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
delete(a.sessions, c.Value) delete(a.sessions, c.Value)
return nil, false 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 { func (a *Auth) TokenUse(id string) *tokenUse {
+92 -5
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"cmp"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,6 +10,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -21,8 +23,11 @@ import (
type Config struct { type Config struct {
Version int `json:"version"` Version int `json:"version"`
Web WebConfig `json:"web"` Web WebConfig `json:"web"`
Admin Admin `json:"admin"` Users []User `json:"users"`
APITokens []APIToken `json:"apiTokens"` 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"` Server Server `json:"server"`
Peers []Peer `json:"peers"` Peers []Peer `json:"peers"`
Log LogConfig `json:"log"` Log LogConfig `json:"log"`
@@ -69,11 +74,24 @@ type Admin struct {
PasswordHash string `json:"passwordHash"` 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 { type APIToken struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Hash string `json:"hash"` 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"` Created time.Time `json:"created"`
} }
@@ -136,12 +154,12 @@ type LogConfig struct {
MaxFiles int `json:"maxFiles"` MaxFiles int `json:"maxFiles"`
} }
const configVersion = 1 const configVersion = 2
// applyDefaults fills zero values. It never overwrites values that are set, // applyDefaults fills zero values. It never overwrites values that are set,
// so a minimal hand-written config.json grows into a complete one. // so a minimal hand-written config.json grows into a complete one.
func (c *Config) applyDefaults() { func (c *Config) applyDefaults() {
if c.Version == 0 { if c.Version < configVersion {
c.Version = configVersion c.Version = configVersion
} }
if c.Web.Listen == "" { if c.Web.Listen == "" {
@@ -160,8 +178,19 @@ func (c *Config) applyDefaults() {
if c.Web.SessionHours == 0 { if c.Web.SessionHours == 0 {
c.Web.SessionHours = 12 c.Web.SessionHours = 12
} }
if c.Admin.Username == "" { if len(c.Users) == 0 {
c.Admin.Username = "admin" 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 s := &c.Server
if s.Interface == "" { if s.Interface == "" {
@@ -237,6 +266,13 @@ func (c *Config) initServer() (bool, error) {
var peerNameRe = regexp.MustCompile(`^[a-zA-Z0-9.@_-]{1,32}$`) 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 { func validatePeerName(name string) error {
switch { switch {
case !peerNameRe.MatchString(name): case !peerNameRe.MatchString(name):
@@ -334,6 +370,33 @@ func (c *Config) validate() error {
return fmt.Errorf("unknown tls.mode %q", c.Web.TLS.Mode) 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{} names := map[string]bool{}
ips := map[netip.Addr]bool{} ips := map[netip.Addr]bool{}
keys := map[string]bool{} keys := map[string]bool{}
@@ -376,6 +439,30 @@ func (c *Config) validate() error {
return nil 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) { func (c *Config) peerByID(id string) (int, *Peer) {
for i := range c.Peers { for i := range c.Peers {
if c.Peers[i].ID == id { if c.Peers[i].ID == id {
+5 -5
View File
@@ -106,7 +106,7 @@ func (p installPlan) apply(c *Config) {
c.Server.ListenPort = p.port c.Server.ListenPort = p.port
} }
if p.passwordHash != "" { 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. // First user, only when nobody has a password yet.
if cur.Admin.PasswordHash == "" { if !cur.passwordSet() {
fmt.Println("\nAdmin account") fmt.Println("\nAdmin account")
for { 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 { if err != nil {
return p, errCancelled return p, errCancelled
} }
@@ -393,6 +393,6 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
fmt.Printf(" Tunnel network %s\n", tunnel) fmt.Printf(" Tunnel network %s\n", tunnel)
fmt.Printf(" Firewall %s must be reachable\n", strings.Join(ports, ", ")) fmt.Printf(" Firewall %s must be reachable\n", strings.Join(ports, ", "))
if p.passwordHash != "" { if p.passwordHash != "" {
fmt.Printf(" Admin %s (password set)\n", next.Admin.Username) fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username)
} }
} }
+27 -9
View File
@@ -53,7 +53,7 @@ func main() {
flag.Usage = usage flag.Usage = usage
configPath := flag.String("config", defaultConfigPath(), "path to config.json; logs and stats are kept next to it") 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") 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") 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") showVersion := flag.Bool("version", false, "print the version and exit")
@@ -66,7 +66,7 @@ func main() {
var err error var err error
switch { switch {
case *passwd: 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) fmt.Fprintf(os.Stderr, "If the service is running: systemctl reload %s\n", serviceName)
} }
case *down: case *down:
@@ -106,12 +106,25 @@ func readSecret(prompt string) (string, error) {
return strings.TrimRight(line, "\r\n"), err 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) store, err := openStore(path)
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@@ -131,10 +144,15 @@ func setPassword(path string) error {
if err != nil { if err != nil {
return err 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 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 return nil
} }
@@ -167,9 +185,9 @@ func run(configPath string) error {
} }
defer logw.Close() defer logw.Close()
slog.Info("starting", "version", version, "config", configPath) slog.Info("starting", "version", version, "config", configPath)
if cfg.Admin.PasswordHash == "" { if !cfg.passwordSet() {
slog.Warn("no admin password set; run: sudo " + installBin + " passwd") slog.Warn("no password set; run: sudo " + installBin + " passwd")
fmt.Fprintf(os.Stderr, "No admin password set. Run: sudo %s passwd\n", installBin) fmt.Fprintf(os.Stderr, "No password set. Run: sudo %s passwd\n", installBin)
} }
kernel, err := newKernel() kernel, err := newKernel()
+136 -3
View File
@@ -218,7 +218,11 @@ func TestAPI(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
hash, _ := hashPassword("a long test password") 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{} k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), 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) t.Fatal(err)
} }
hash, _ := hashPassword("a long test password") 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{} k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), 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 { fresh := func() *Config {
c := &Config{} c := &Config{}
c.applyDefaults() c.applyDefaults()
c.Admin.PasswordHash = hash // skips the password question c.Users[0].PasswordHash = hash // skips the password question
return c return c
} }
@@ -738,3 +746,128 @@ func TestLatency(t *testing.T) {
t.Fatalf("history %+v", last) 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)
}
+11 -6
View File
@@ -43,8 +43,8 @@ Usage (as root):
replace the installed binary with this one and restart replace the installed binary with this one and restart
%s uninstall [-purge] [-y] %s uninstall [-purge] [-y]
remove the service, interface and firewall table (-purge also deletes %s) remove the service, interface and firewall table (-purge also deletes %s)
%s passwd %s passwd [username]
set the admin password of the installed service set a user's password (default: the first user) of the installed service
%s version %s version
Without a command it runs the service: Without a command it runs the service:
@@ -433,9 +433,9 @@ func cmdInstall(args []string) error {
return err return err
} }
if store.Get().Admin.PasswordHash == "" { if cfg := store.Get(); !cfg.passwordSet() {
fmt.Println("\nChoose the admin password for the web interface (user \"admin\", at least 12 characters).") 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 { if err := setPassword(configFile, ""); err != nil {
return err return err
} }
} }
@@ -571,6 +571,11 @@ func cmdUpdate(args []string) error {
if rErr := copyFile(oldBin, installBin, 0o755, uid, gid); rErr != nil { 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) 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 { if rErr := restartAndVerify(); rErr != nil {
return fmt.Errorf("update failed and the old version does not start either: %v (original error: %w)", rErr, err) 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" { if os.Geteuid() != 0 && runtime.GOOS == "linux" {
return errors.New("run as root, e.g. with sudo") 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 return err
} }
if *path == configFile && shOut("systemctl", "is-active", serviceName) == "active" { if *path == configFile && shOut("systemctl", "is-active", serviceName) == "active" {
+210
View File
@@ -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})
}