From f8f634fbf722fc75a543394c72b391db1ad09093 Mon Sep 17 00:00:00 2001
From: Reactorcoremeltdown
Date: Mon, 2 Nov 2026 15:37:47 +0100
Subject: [PATCH] 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
---
docs/HANDOVER.md | 38 +++-
docs/PROTOCOL.md | 12 +-
internal/server/account.go | 316 ++++++++++++++++++++++++++++
internal/server/account_test.go | 276 +++++++++++++++++++++++++
internal/server/admin.go | 5 +
internal/server/auth.go | 48 -----
internal/server/oidc.go | 4 +-
internal/server/pages.go | 20 +-
internal/server/pagesadmin.go | 6 +
internal/server/password_test.go | 14 +-
internal/server/server.go | 56 ++++-
internal/server/web.go | 48 ++---
internal/server/webaccount.go | 340 +++++++++++++++++++++++++++++++
internal/server/webadmin.go | 3 +
internal/store/model.go | 12 +-
packaging/deb/server/changelog | 12 ++
versions.mk | 2 +-
17 files changed, 1086 insertions(+), 126 deletions(-)
create mode 100644 internal/server/account.go
create mode 100644 internal/server/account_test.go
create mode 100644 internal/server/webaccount.go
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 83e7ef8..d4fbf6d 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -436,11 +436,12 @@ browsing, by design.
| Page | What it does |
| --- | --- |
-| `/web/vaults` | the vaults you can reach, the create-a-vault form, your own account password |
+| `/web/vaults` | the vaults you can reach, the create-a-vault form |
+| `/web/account` | everyone's own settings: account details, every signed-in browser and client with a button to sign each out (or all but this one), two-factor setup, replacement and removal, the password |
| `/web/vaults/{id}/delete` | what deleting a vault destroys, and the password that confirms it |
| `/web/groups` | the group tree you can see, and a form to add a group inside one you administer |
| `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion |
-| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, promote, demote, delete |
+| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, sign out everywhere, promote, demote, delete |
| `/web/invites` | outstanding invite links with their use counts and expiry, a form to make one, a button to revoke one |
The navigation offers the last three only to somebody who administers a
@@ -478,6 +479,29 @@ No page carries any JavaScript. Each control is its own small form with the
CSRF token and one action in it, which is what makes the interface work in a
locked-down browser and keeps its attack surface down to that token.
+**The account page.** `/web/account`, offered to everyone, is where a
+person manages how they sign in. The rules are in `account.go`, shared with
+the `/api/v1/auth/sessions` and `/api/v1/auth/totp` endpoints:
+
+- *Signed-in devices* lists every live session — browsers, named from their
+ User-Agent at sign-in ("Firefox on macOS"), and clients, named by the
+ label they sent — with when each was last used. Any of them can be signed
+ out, or all but the current one. Pages and responses refer to a session by
+ `sessionID`, a digest of its token, so the list never carries a token.
+ `LastSeenAt` is refreshed by `markSeen` at most every five minutes, because
+ each refresh rewrites the state document.
+- *Two-factor authentication* turns on with nothing more than the session,
+ since it only adds a check. Replacing or removing an active factor asks
+ for the password and a current code: before this, `totp/enroll` reset the
+ factor on any live session, so a borrowed laptop could quietly disarm it.
+- *Password* moved here from the vault page; changing it still signs out the
+ other sessions. An account that signs in only through single sign-on sees
+ neither form, and is told that its provider manages both.
+
+A server administrator also has *Sign out everywhere* on `/web/users`, for a
+colleague's lost phone: every session of that account goes, and the account
+stays usable.
+
**Deleting a vault asks for the password again.** Every other control on
these pages is undoable or additive; this one erases every stored version of
a file. A live session is what an unlocked laptop leaves lying around, so the
@@ -874,9 +898,13 @@ what an arm64 Linux build host needs.
`targetSdk` is its own change.
- **No key-file or Yubikey handling**, and none needed: those live in the
KeePass application.
-- **Sessions are not listable or revocable individually** beyond
- `keeporg logout` on the device itself. Adding `GET`/`DELETE
- /api/v1/auth/sessions` is a small, well-bounded job.
+- **No QR code for two-factor setup.** The account page shows the key and
+ an `otpauth:` link, which a phone opens straight into its authenticator;
+ scanning from a desktop screen needs a QR encoder, which the standard
+ library does not have.
+- **The CLI has no `sessions` command yet.** The API is there
+ (`/api/v1/auth/sessions`); the web page is the way to see and revoke
+ devices meanwhile.
- **Android does not restart its watcher at boot.** Add a
`RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission
it costs.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index dc03e85..f0fb990 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -21,13 +21,23 @@ https, and every form carries a per-session CSRF token.
| POST | `/api/v1/auth/logout` | – | revokes the calling token |
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships |
| POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked |
-| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed |
+| POST | `/api/v1/auth/totp/enroll` | `{password, totp}`, only when a factor is already on | `{secret, uri}`; not active until confirmed. Replacing an active factor without the password and current code is `403 reauth_required` |
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
+| DELETE | `/api/v1/auth/totp` | `{password, totp}` | `{totp_enabled: false}`; `403 reauth_required`, `403 bad_credentials`, `403 totp_invalid` |
+| GET | `/api/v1/auth/sessions` | – | `{sessions: [{id, label, browser, current, created_at, last_seen_at, expires_at}]}`, the caller's own |
+| DELETE | `/api/v1/auth/sessions/{id}` | – | signs that session out; `404` when it is not the caller's |
+| DELETE | `/api/v1/auth/sessions` | – | `{revoked: n}`: every session of the caller but this one |
| POST | `/api/v1/auth/device/start` | `{label, return_url}` | `{device_code, user_code, verification_url, interval, expires_in}`; `400` when `return_url` is not a `keeporg:` URL |
| POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` |
A device code is single use: the first successful poll consumes it.
+A session `id` is a one-way digest of its token, never the token itself, so
+a list of sessions cannot be used to sign in as any of them. Failed
+re-authentication answers `403`, not `401`, so a client that drops its
+token on `401` does not sign itself out over a mistyped password.
+`last_seen_at` is refreshed at most every five minutes.
+
`return_url` is optional (server 0.9 and later). When it is given, the page
that ends the approval, after a password or single sign-on, carries a
"Return to KeepOrg" link to it. Only the `keeporg:` scheme is accepted, so
diff --git a/internal/server/account.go b/internal/server/account.go
new file mode 100644
index 0000000..5109261
--- /dev/null
+++ b/internal/server/account.go
@@ -0,0 +1,316 @@
+package server
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+
+ "cc.tiredsysadmin.keeporg/internal/store"
+ "cc.tiredsysadmin.keeporg/internal/totp"
+)
+
+// A person's own account: the sessions it holds on each device, and its
+// second factor. As with administration, every change is a function over the
+// state taking the caller, and the JSON handlers below and the settings page
+// in webaccount.go are both thin wrappers over them.
+
+var (
+ // errNoSuchSession is a session that does not exist or is not the
+ // caller's; the two are not told apart.
+ errNoSuchSession = errors.New("no such session")
+ // errNoPassword is an account that signs in only through single
+ // sign-on. Its second factor belongs at the identity provider, and there
+ // is no password to change.
+ errNoPassword = errors.New("this account signs in through single sign-on and has no password here")
+ // errReauth asks for the password and, once enrolled, the one-time code,
+ // before a change that would weaken how the account signs in. A live
+ // session is what an unlocked laptop leaves lying around.
+ errReauth = errors.New("confirm with your password and one-time code")
+)
+
+// sessionID is a session's public name: a one-way digest of its token, so a
+// page or a response can point at a session without carrying the bearer
+// token that would sign in as it.
+func sessionID(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:8])
+}
+
+// sessionView is one signed-in device as its owner sees it.
+type sessionView struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Browser bool `json:"browser"`
+ Current bool `json:"current"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+// sessionsOf lists a user's live sessions, the caller's own first and the
+// rest by most recent use.
+func sessionsOf(st *store.State, userID, current string) []sessionView {
+ out := []sessionView{}
+ now := time.Now()
+ for token, sess := range st.Sessions {
+ if sess.UserID != userID || now.After(sess.ExpiresAt) {
+ continue
+ }
+ seen := sess.LastSeenAt
+ if seen.IsZero() {
+ seen = sess.CreatedAt
+ }
+ out = append(out, sessionView{
+ ID: sessionID(token), Label: sess.Label, Browser: sess.IsBrowser(),
+ Current: token == current, CreatedAt: sess.CreatedAt,
+ LastSeenAt: seen, ExpiresAt: sess.ExpiresAt,
+ })
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Current != out[j].Current {
+ return out[i].Current
+ }
+ return out[i].LastSeenAt.After(out[j].LastSeenAt)
+ })
+ return out
+}
+
+// revokeSession signs one of the caller's sessions out. The device holding
+// it is refused from its next request on.
+func revokeSession(st *store.State, userID, id string) error {
+ for token, sess := range st.Sessions {
+ if sess.UserID == userID && sessionID(token) == id {
+ delete(st.Sessions, token)
+ return nil
+ }
+ }
+ return errNoSuchSession
+}
+
+// revokeOtherSessions signs out every session of the caller but keep, and
+// says how many went.
+func revokeOtherSessions(st *store.State, userID, keep string) int {
+ n := 0
+ for token, sess := range st.Sessions {
+ if sess.UserID == userID && token != keep {
+ delete(st.Sessions, token)
+ n++
+ }
+ }
+ return n
+}
+
+// reauth checks the caller's password, and their one-time code when one is
+// enrolled, for a change that weakens or replaces how the account signs in.
+func reauth(st *store.State, u *store.User, password, code string) error {
+ if u.PassHash == "" {
+ return errNoPassword
+ }
+ if password == "" {
+ return errReauth
+ }
+ _, err := checkPassword(st, u.Username, password, code)
+ return err
+}
+
+// beginTOTP starts enrolling a second factor and returns its secret. Until
+// confirmTOTP sees a code from it, the account signs in as before. Replacing
+// a factor that is already active needs the password and the current code,
+// because a new secret disarms the old one.
+func beginTOTP(st *store.State, userID, password, code string) (string, error) {
+ u := st.Users[userID]
+ if u == nil {
+ return "", errNoSuchSession
+ }
+ if u.PassHash == "" {
+ return "", errNoPassword
+ }
+ if u.TOTPConfirmed {
+ if err := reauth(st, u, password, code); err != nil {
+ return "", err
+ }
+ }
+ u.TOTPSecret, u.TOTPConfirmed = totp.NewSecret(), false
+ return u.TOTPSecret, nil
+}
+
+// confirmTOTP turns the second factor on, once a code from it checks out.
+func confirmTOTP(st *store.State, userID, code string) error {
+ u := st.Users[userID]
+ if u == nil || u.TOTPSecret == "" {
+ return errors.New("start setting up a second factor first")
+ }
+ if u.TOTPConfirmed {
+ return errors.New("the second factor is already on")
+ }
+ if !totp.Check(u.TOTPSecret, code, time.Now()) {
+ return errTOTPInvalid
+ }
+ u.TOTPConfirmed = true
+ return nil
+}
+
+// cancelTOTP abandons an enrolment that was never confirmed.
+func cancelTOTP(st *store.State, userID string) {
+ if u := st.Users[userID]; u != nil && !u.TOTPConfirmed {
+ u.TOTPSecret = ""
+ }
+}
+
+// disableTOTP turns the second factor off, behind the password and a
+// current code.
+func disableTOTP(st *store.State, userID, password, code string) error {
+ u := st.Users[userID]
+ if u == nil {
+ return errNoSuchSession
+ }
+ if !u.TOTPConfirmed {
+ return errors.New("no second factor is on")
+ }
+ if err := reauth(st, u, password, code); err != nil {
+ return err
+ }
+ u.TOTPSecret, u.TOTPConfirmed = "", false
+ return nil
+}
+
+// failAccount maps an account error onto a response. Re-authentication
+// failures are 403, not 401: a client that drops its token on 401 must not
+// sign itself out over a mistyped password.
+func failAccount(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, errNoSuchSession):
+ fail(w, http.StatusNotFound, "not_found", err.Error())
+ case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired):
+ fail(w, http.StatusForbidden, "reauth_required", errReauth.Error())
+ case errors.Is(err, errTOTPInvalid):
+ fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected")
+ case errors.Is(err, errBadCredentials):
+ fail(w, http.StatusForbidden, "bad_credentials", "the password is wrong")
+ case errors.Is(err, errNoPassword):
+ fail(w, http.StatusConflict, "no_password", err.Error())
+ default:
+ fail(w, http.StatusBadRequest, "bad_request", err.Error())
+ }
+}
+
+// ---- API ----
+
+func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request, u *store.User) {
+ var out []sessionView
+ s.st.Read(func(st *store.State) { out = sessionsOf(st, u.ID, bearer(r)) })
+ writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
+}
+
+func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request, u *store.User) {
+ if err := s.st.Update(func(st *store.State) error {
+ return revokeSession(st, u.ID, r.PathValue("id"))
+ }); err != nil {
+ failAccount(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
+}
+
+func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request, u *store.User) {
+ var n int
+ if err := s.st.Update(func(st *store.State) error {
+ n = revokeOtherSessions(st, u.ID, bearer(r))
+ return nil
+ }); err != nil {
+ fail(w, http.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]int{"revoked": n})
+}
+
+// reauthRequest is the body of a change that may need re-authentication.
+// It is optional where nothing is enrolled yet.
+type reauthRequest struct {
+ Password string `json:"password"`
+ TOTP string `json:"totp"`
+}
+
+func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
+ var req reauthRequest
+ if r.ContentLength > 0 && !readJSON(w, r, &req) {
+ return
+ }
+ var sec string
+ if err := s.st.Update(func(st *store.State) error {
+ var err error
+ sec, err = beginTOTP(st, u.ID, req.Password, req.TOTP)
+ return err
+ }); err != nil {
+ failAccount(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{
+ "secret": sec,
+ "uri": totp.URI("KeepOrg", u.Username, sec),
+ })
+}
+
+func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
+ var req struct {
+ Code string `json:"code"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if err := s.st.Update(func(st *store.State) error {
+ return confirmTOTP(st, u.ID, req.Code)
+ }); err != nil {
+ if errors.Is(err, errTOTPInvalid) {
+ fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
+ return
+ }
+ failAccount(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
+}
+
+func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request, u *store.User) {
+ var req reauthRequest
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if err := s.st.Update(func(st *store.State) error {
+ return disableTOTP(st, u.ID, req.Password, req.TOTP)
+ }); err != nil {
+ failAccount(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": false})
+}
+
+// browserLabel names a browser session from its User-Agent, as "Firefox on
+// macOS". It is a label for a person to recognise, not a fingerprint, so a
+// handful of substrings is enough.
+func browserLabel(ua string) string {
+ browser := "Browser"
+ for _, b := range []struct{ token, name string }{
+ {"Edg/", "Edge"}, {"OPR/", "Opera"}, {"Firefox/", "Firefox"},
+ {"Chrome/", "Chrome"}, {"Safari/", "Safari"},
+ } {
+ if strings.Contains(ua, b.token) {
+ browser = b.name
+ break
+ }
+ }
+ for _, o := range []struct{ token, name string }{
+ {"Android", "Android"}, {"iPhone", "iOS"}, {"iPad", "iPadOS"},
+ {"CrOS", "ChromeOS"}, {"Mac OS X", "macOS"}, {"Windows", "Windows"},
+ {"Linux", "Linux"}, {"BSD", "BSD"},
+ } {
+ if strings.Contains(ua, o.token) {
+ return browser + " on " + o.name
+ }
+ }
+ return browser
+}
diff --git a/internal/server/account_test.go b/internal/server/account_test.go
new file mode 100644
index 0000000..951ea3d
--- /dev/null
+++ b/internal/server/account_test.go
@@ -0,0 +1,276 @@
+package server
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "cc.tiredsysadmin.keeporg/internal/secret"
+ "cc.tiredsysadmin.keeporg/internal/store"
+ "cc.tiredsysadmin.keeporg/internal/totp"
+)
+
+func sessionList(t *testing.T, h *harness, token string) []map[string]any {
+ t.Helper()
+ status, body := h.call("GET", "/api/v1/auth/sessions", token, nil)
+ if status != http.StatusOK {
+ t.Fatalf("list sessions: %d %v", status, body)
+ }
+ var out []map[string]any
+ for _, s := range body["sessions"].([]any) {
+ out = append(out, s.(map[string]any))
+ }
+ return out
+}
+
+func TestSessionsAreListedAndRevokedByTheirOwner(t *testing.T) {
+ h := newHarness(t)
+ laptop, _, _ := h.login("admin", adminPassword, "")
+ phone, _, _ := h.login("admin", adminPassword, "")
+
+ list := sessionList(t, h, laptop)
+ if len(list) != 2 || list[0]["current"] != true {
+ t.Fatalf("sessions as the laptop sees them: %v", list)
+ }
+ for _, s := range list {
+ if id := s["id"].(string); id == laptop || id == phone || strings.Contains(laptop+phone, id) && len(id) > 16 {
+ t.Fatalf("a session id gives its token away: %v", s)
+ }
+ }
+
+ // The laptop signs the phone out; the phone is refused from then on.
+ phoneID := sessionID(phone)
+ if status, body := h.call("DELETE", "/api/v1/auth/sessions/"+phoneID, laptop, nil); status != http.StatusOK {
+ t.Fatalf("revoke: %d %v", status, body)
+ }
+ if status, _ := h.call("GET", "/api/v1/auth/whoami", phone, nil); status != http.StatusUnauthorized {
+ t.Fatalf("the revoked phone still works: %d", status)
+ }
+
+ // Somebody else's session is not found, and stays signed in.
+ other := h.makeUser("bob", "bob-password-1")
+ if status, _ := h.call("DELETE", "/api/v1/auth/sessions/"+sessionID(other), laptop, nil); status != http.StatusNotFound {
+ t.Fatalf("revoking another account's session: %d", status)
+ }
+ if status, _ := h.call("GET", "/api/v1/auth/whoami", other, nil); status != http.StatusOK {
+ t.Fatal("another account's session was revoked")
+ }
+
+ // Sign out everything else.
+ h.login("admin", adminPassword, "")
+ h.login("admin", adminPassword, "")
+ status, body := h.call("DELETE", "/api/v1/auth/sessions", laptop, nil)
+ if status != http.StatusOK || body["revoked"] != float64(2) {
+ t.Fatalf("revoke others: %d %v", status, body)
+ }
+ if list := sessionList(t, h, laptop); len(list) != 1 {
+ t.Fatalf("after revoking the others: %v", list)
+ }
+}
+
+func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
+ h := newHarness(t)
+ tok, _, _ := h.login("admin", adminPassword, "")
+
+ // Turning it on needs nothing beyond the session: it only adds a check.
+ status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
+ if status != http.StatusOK {
+ t.Fatalf("enroll: %d %v", status, body)
+ }
+ sec := body["secret"].(string)
+ code, _ := totp.Code(sec, time.Now())
+ if status, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": code}); status != http.StatusOK {
+ t.Fatalf("confirm: %d", status)
+ }
+
+ // A session alone can no longer swap the secret or turn it off.
+ if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" {
+ t.Fatalf("re-enroll without a password: %d %v", status, body)
+ }
+ if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
+ map[string]string{"password": adminPassword}); status != http.StatusForbidden || body["code"] != "reauth_required" {
+ t.Fatalf("disable without a code: %d %v", status, body)
+ }
+ if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
+ map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
+ t.Fatalf("disable with a wrong password: %d %v", status, body)
+ }
+ var on bool
+ h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed })
+ if !on {
+ t.Fatal("a refused request turned the second factor off")
+ }
+
+ if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
+ map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
+ t.Fatalf("disable: %d %v", status, body)
+ }
+ if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusOK {
+ t.Fatal("still asked for a code after turning it off")
+ }
+}
+
+func TestSecondFactorIsRefusedForSSOOnlyAccounts(t *testing.T) {
+ h := newHarness(t)
+ if err := h.srv.Store().Update(func(st *store.State) error {
+ st.Users["sso"] = &store.User{ID: "sso", Username: "ssouser", OIDCIssuer: "https://id.example", OIDCSubject: "s"}
+ st.Sessions["sso-token"] = &store.Session{Token: "sso-token", UserID: "sso", ExpiresAt: time.Now().Add(time.Hour)}
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if status, body := h.call("POST", "/api/v1/auth/totp/enroll", "sso-token", nil); status != http.StatusConflict {
+ t.Fatalf("enroll on an SSO-only account: %d %v", status, body)
+ }
+}
+
+var sessionButton = regexp.MustCompile(`name="session" value="([0-9a-f]+)"`)
+
+func TestAccountPageSignsOutOtherDevices(t *testing.T) {
+ h := newHarness(t)
+ device, _, _ := h.login("admin", adminPassword, "")
+ b := newBrowser(t, h)
+ csrf := b.signIn()
+
+ _, page := b.get("/web/account")
+ for _, want := range []string{"Signed-in devices", "this browser", "test", "Two-factor authentication", "Change password"} {
+ if !strings.Contains(page, want) {
+ t.Fatalf("account page lacks %q:\n%s", want, page)
+ }
+ }
+ if strings.Contains(page, device) || strings.Contains(page, b.cookie()) {
+ t.Fatal("the page carries a session token")
+ }
+ ids := sessionButton.FindAllStringSubmatch(page, -1)
+ if len(ids) != 1 || ids[0][1] != sessionID(device) {
+ t.Fatalf("revoke buttons %v, want only the device's", ids)
+ }
+
+ _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"revoke"}, "session": {ids[0][1]}})
+ if !strings.Contains(page, "That device is signed out") {
+ t.Fatalf("no confirmation:\n%s", page)
+ }
+ if status, _ := h.call("GET", "/api/v1/databases", device, nil); status != http.StatusUnauthorized {
+ t.Fatalf("the device still syncs after being signed out: %d", status)
+ }
+
+ // Revoking this browser's own session from the list signs it out.
+ _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"revoke"},
+ "session": {sessionID(b.cookie())}})
+ if !strings.Contains(page, "Sign in to create") || b.cookie() != "" {
+ t.Fatalf("revoking this browser left it signed in:\n%s", page)
+ }
+}
+
+func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
+ h := newHarness(t)
+ b := newBrowser(t, h)
+ csrf := b.signIn()
+
+ _, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}})
+ if !strings.Contains(page, `href="otpauth://totp/`) {
+ t.Fatalf("no authenticator link:\n%s", page)
+ }
+ var sec string
+ h.srv.Store().Read(func(st *store.State) { sec = st.Users["admin"].TOTPSecret })
+ if !strings.Contains(page, groupKey(sec)) {
+ t.Fatalf("the key is not shown:\n%s", page)
+ }
+
+ _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {"000000"}})
+ if !strings.Contains(page, "rejected") {
+ t.Fatalf("a wrong code was not refused:\n%s", page)
+ }
+ code, _ := totp.Code(sec, time.Now())
+ _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {code}})
+ if !strings.Contains(page, "Two-factor authentication is on") {
+ t.Fatalf("not turned on:\n%s", page)
+ }
+ if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusUnauthorized {
+ t.Fatal("a password alone still signs in")
+ }
+
+ // Turning it off from the page needs the password and a code.
+ _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"}, "password": {adminPassword}})
+ if !strings.Contains(page, "Confirm with your password") {
+ t.Fatalf("turned off without a code:\n%s", page)
+ }
+ _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"},
+ "password": {adminPassword}, "totp": {code}})
+ if !strings.Contains(page, "Two-factor authentication is off") {
+ t.Fatalf("not turned off:\n%s", page)
+ }
+}
+
+func TestAdministratorSignsSomebodyOutEverywhere(t *testing.T) {
+ h := newHarness(t)
+ bob := h.makeUser("bob", "bob-password-1")
+ b := newBrowser(t, h)
+ csrf := b.signIn()
+ var bobID string
+ h.srv.Store().Read(func(st *store.State) { bobID = st.UserByName("bob").ID })
+
+ _, page := b.post("/web/users", url.Values{"csrf": {csrf}, "user": {bobID}, "action": {"sign-out"}})
+ if !strings.Contains(page, "signed out on every device") {
+ t.Fatalf("no confirmation:\n%s", page)
+ }
+ if status, _ := h.call("GET", "/api/v1/auth/whoami", bob, nil); status != http.StatusUnauthorized {
+ t.Fatal("bob's session survived")
+ }
+ if _, status, _ := h.login("bob", "bob-password-1", ""); status != http.StatusOK {
+ t.Fatal("signing out everywhere locked the account")
+ }
+}
+
+func TestLastSeenIsRecordedCoarsely(t *testing.T) {
+ h := newHarness(t)
+ tok, _, _ := h.login("admin", adminPassword, "")
+ old := time.Now().Add(-time.Hour)
+ _ = h.srv.Store().Update(func(st *store.State) error {
+ st.Sessions[tok].LastSeenAt = old
+ return nil
+ })
+ h.call("GET", "/api/v1/auth/whoami", tok, nil)
+ var seen time.Time
+ h.srv.Store().Read(func(st *store.State) { seen = st.Sessions[tok].LastSeenAt })
+ if time.Since(seen) > time.Minute {
+ t.Fatalf("a stale last-seen was not refreshed: %v", seen)
+ }
+}
+
+func TestBrowserLabel(t *testing.T) {
+ for ua, want := range map[string]string{
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5; rv:128.0) Gecko/20100101 Firefox/128.0": "Firefox on macOS",
+ "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Mobile Safari/537.36": "Chrome on Android",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0": "Edge on Windows",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile Safari/604.1": "Safari on iOS",
+ "": "Browser",
+ } {
+ if got := browserLabel(ua); got != want {
+ t.Errorf("browserLabel(%q) = %q, want %q", ua, got, want)
+ }
+ }
+}
+
+// makeUser adds a password account and returns a session token for it.
+func (h *harness) makeUser(name, password string) string {
+ h.t.Helper()
+ hash, err := secret.Hash(password)
+ if err != nil {
+ h.t.Fatal(err)
+ }
+ if err := h.srv.Store().Update(func(st *store.State) error {
+ st.Users[name] = &store.User{ID: name, Username: name, PassHash: hash, CreatedAt: time.Now()}
+ return nil
+ }); err != nil {
+ h.t.Fatal(err)
+ }
+ tok, status, body := h.login(name, password, "")
+ if status != http.StatusOK {
+ h.t.Fatalf("login %s: %d %v", name, status, body)
+ }
+ return tok
+}
diff --git a/internal/server/admin.go b/internal/server/admin.go
index cfc1328..fe95617 100644
--- a/internal/server/admin.go
+++ b/internal/server/admin.go
@@ -324,6 +324,7 @@ const (
accountPromote accountAction = "promote"
accountDemote accountAction = "demote"
accountClearTOTP accountAction = "clear-totp"
+ accountSignOut accountAction = "sign-out"
accountDelete accountAction = "delete"
)
@@ -361,6 +362,10 @@ func adminAccount(st *store.State, u *store.User, ref string, act accountAction)
target.ServerAdmin = false
case accountClearTOTP:
target.TOTPSecret, target.TOTPConfirmed = "", false
+ case accountSignOut:
+ // For a lost phone or laptop: every device must sign in again,
+ // and the account stays usable.
+ revokeSessions(st, target.ID)
case accountDelete:
delete(st.Users, target.ID)
revokeSessions(st, target.ID)
diff --git a/internal/server/auth.go b/internal/server/auth.go
index f5b4961..7c18273 100644
--- a/internal/server/auth.go
+++ b/internal/server/auth.go
@@ -136,54 +136,6 @@ func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.U
writeJSON(w, http.StatusOK, out)
}
-func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
- sec := totp.NewSecret()
- if err := s.st.Update(func(st *store.State) error {
- target := st.Users[u.ID]
- if target == nil {
- return errBadCredentials
- }
- target.TOTPSecret = sec
- target.TOTPConfirmed = false
- return nil
- }); err != nil {
- fail(w, http.StatusInternalServerError, "internal", err.Error())
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{
- "secret": sec,
- "uri": totp.URI("KeepOrg", u.Username, sec),
- })
-}
-
-func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
- var req struct {
- Code string `json:"code"`
- }
- if !readJSON(w, r, &req) {
- return
- }
- err := s.st.Update(func(st *store.State) error {
- target := st.Users[u.ID]
- if target == nil || target.TOTPSecret == "" {
- return errBadCredentials
- }
- if !totp.Check(target.TOTPSecret, req.Code, time.Now()) {
- return errTOTPInvalid
- }
- target.TOTPConfirmed = true
- return nil
- })
- switch {
- case errors.Is(err, errTOTPInvalid):
- fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
- case err != nil:
- fail(w, http.StatusInternalServerError, "internal", err.Error())
- default:
- writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
- }
-}
-
// ---- device login: the browser approves, the client polls ----
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/server/oidc.go b/internal/server/oidc.go
index e473914..ea13646 100644
--- a/internal/server/oidc.go
+++ b/internal/server/oidc.go
@@ -248,15 +248,15 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
return err
}
dev.OIDCState = ""
- token := s.newSession(st, u.ID, dev.Label).Token
if dev.Web {
+ token := s.newBrowserSession(st, u.ID, r).Token
// Nothing is waiting to poll for this one; the browser in front
// of us is the client.
webToken = token
delete(st.Devices, dev.DeviceCode)
return nil
}
- dev.SessionToken = token
+ dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
returnURL = dev.ReturnURL
return nil
}); err != nil {
diff --git a/internal/server/pages.go b/internal/server/pages.go
index b82cdab..89b45fb 100644
--- a/internal/server/pages.go
+++ b/internal/server/pages.go
@@ -74,6 +74,7 @@ td button{width:auto;margin:0 .25rem .25rem 0;padding:.3rem .5rem;font-size:.8re
background:transparent;color:var(--accent);border:1px solid var(--line)}
td button.danger,a.danger{color:#b02a37}
button.destroy{background:#b02a37}
+button.secondary{margin-top:.6rem;background:transparent;color:var(--accent);border:1px solid var(--line)}
@media(prefers-color-scheme:dark){td button.danger,a.danger{color:#f2b8c0}
button.destroy{background:#a3313c}}
.inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0}
@@ -186,23 +187,8 @@ by whoever controls the server. Any other setting keeps the key off the server.<
vault. Ask an administrator to add you to one.
{{end}}
-
-Change your account password
-This is the password you sign in with, not any vault's password.
-Changing it signs out your other sessions.
-
-
+Your password, two-factor authentication and signed-in devices
+are on the Account page.
{{end}}`))
var deleteVaultTmpl = template.Must(template.Must(pages.Clone()).Parse(`
diff --git a/internal/server/pagesadmin.go b/internal/server/pagesadmin.go
index 15ab76f..8bc2102 100644
--- a/internal/server/pagesadmin.go
+++ b/internal/server/pagesadmin.go
@@ -216,6 +216,12 @@ groups you administer.{{end}}
{{end}}
{{if not .Self}}
+