Add an Account page for sign-in methods and signed-in devices

/web/account, offered to everyone, lists every browser and client signed
in as you, with when each was last used, and signs any of them out, or all
but the current one. It sets up, replaces and removes the second factor,
and holds the password form, moved from the vault page. Administrators can
sign a user out everywhere from the People page.

The API gains GET and DELETE /api/v1/auth/sessions and DELETE
/api/v1/auth/totp. Sessions are named by a digest of their token, never the
token, and record when they were last used. Replacing or removing an active
second factor now needs the password and a current code.

Server version 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 15:37:47 +01:00
co-authored by Claude Opus 5.5
parent 318bd68908
commit f8f634fbf7
17 changed files with 1086 additions and 126 deletions
+7 -7
View File
@@ -95,25 +95,25 @@ func TestWebChangesThePassword(t *testing.T) {
b := newBrowser(t, h)
csrf := b.signIn()
// The form is on the page, and it is guarded like the others.
_, page := b.get("/web/vaults")
if !strings.Contains(page, "Change your account password") {
t.Fatalf("no password form on the vault page:\n%s", page)
// The form is on the account page, and it is guarded like the others.
_, page := b.get("/web/account")
if !strings.Contains(page, "Change password") {
t.Fatalf("no password form on the account page:\n%s", page)
}
if status, _ := b.post("/web/password", url.Values{
if status, _ := b.post("/web/account", url.Values{"action": {"password"},
"current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {newAdminPassword}}); status != http.StatusForbidden {
t.Fatalf("a form without a CSRF token returned %d", status)
}
_, page = b.post("/web/password", url.Values{"csrf": {csrf},
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"password"},
"current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {"something-else"}})
if !strings.Contains(page, "do not match") {
t.Fatalf("mismatch not reported:\n%s", page)
}
_, page = b.post("/web/password", url.Values{"csrf": {csrf},
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"password"},
"current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {newAdminPassword}})
if !strings.Contains(page, "Password changed") {