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}} +
+ + + + +
diff --git a/internal/server/password_test.go b/internal/server/password_test.go index 2aa991d..7172006 100644 --- a/internal/server/password_test.go +++ b/internal/server/password_test.go @@ -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") { diff --git a/internal/server/server.go b/internal/server/server.go index e63e85b..f2f322b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -177,6 +177,10 @@ func (s *Server) routes() { m.HandleFunc("POST /api/v1/auth/password", s.authed(s.handleChangePassword)) m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll)) m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm)) + m.HandleFunc("DELETE /api/v1/auth/totp", s.authed(s.handleTOTPDisable)) + m.HandleFunc("GET /api/v1/auth/sessions", s.authed(s.handleListSessions)) + m.HandleFunc("DELETE /api/v1/auth/sessions", s.authed(s.handleRevokeOtherSessions)) + m.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.authed(s.handleRevokeSession)) m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart) m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) @@ -191,7 +195,8 @@ func (s *Server) routes() { m.HandleFunc("POST /web/signout", s.webForm(s.handleWebSignOut)) m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults)) m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault)) - m.HandleFunc("POST /web/password", s.webForm(s.handleWebChangePassword)) + m.HandleFunc("GET /web/account", s.webPage(s.handleWebAccount)) + m.HandleFunc("POST /web/account", s.webForm(s.handleWebAccountAction)) m.HandleFunc("GET /web/vaults/{id}/delete", s.webPage(s.handleWebDeleteVaultPage)) m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault)) m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups)) @@ -293,7 +298,10 @@ func (s *Server) userFromRequest(r *http.Request) (*store.User, error) { if tok == "" { return nil, errUnauthorized } - var out *store.User + var ( + out *store.User + seen time.Time + ) s.st.Read(func(st *store.State) { sess := st.Sessions[tok] if sess == nil || time.Now().After(sess.ExpiresAt) { @@ -301,15 +309,35 @@ func (s *Server) userFromRequest(r *http.Request) (*store.User, error) { } if u := st.Users[sess.UserID]; u != nil && !u.Disabled { cp := *u - out = &cp + out, seen = &cp, sess.LastSeenAt } }) if out == nil { return nil, errUnauthorized } + s.markSeen(tok, seen) return out, nil } +// seenResolution is how stale a session's LastSeenAt may get before a +// request refreshes it. Each refresh rewrites the state document, so it is +// minutes, not seconds; the devices list only needs to tell today from last +// month. +const seenResolution = 5 * time.Minute + +// markSeen records that a session was used, when its record is stale. +func (s *Server) markSeen(token string, last time.Time) { + if time.Since(last) < seenResolution { + return + } + _ = s.st.Update(func(st *store.State) error { + if sess := st.Sessions[token]; sess != nil { + sess.LastSeenAt = time.Now() + } + return nil + }) +} + // bearer extracts a token from the Authorization header, or from the // access_token query parameter, which EventSource clients need because they // cannot set headers. @@ -322,18 +350,28 @@ func bearer(r *http.Request) string { } func (s *Server) newSession(st *store.State, userID, label string) *store.Session { + now := time.Now() sess := &store.Session{ - Token: secret.Token(), - UserID: userID, - Label: label, - CSRF: secret.Token(), - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(s.cfg.SessionTTL), + Token: secret.Token(), + UserID: userID, + Label: label, + CSRF: secret.Token(), + CreatedAt: now, + ExpiresAt: now.Add(s.cfg.SessionTTL), + LastSeenAt: now, } st.Sessions[sess.Token] = sess return sess } +// newBrowserSession is a session for a web cookie, named after the browser +// so that a list of them can be told apart. +func (s *Server) newBrowserSession(st *store.State, userID string, r *http.Request) *store.Session { + sess := s.newSession(st, userID, browserLabel(r.UserAgent())) + sess.Browser = true + return sess +} + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { var users, dbs int s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) }) diff --git a/internal/server/web.go b/internal/server/web.go index f354fde..f35924a 100644 --- a/internal/server/web.go +++ b/internal/server/web.go @@ -20,8 +20,9 @@ const webCookie = "keeporg_web" // webSession is a signed-in browser. type webSession struct { - user *store.User - csrf string + user *store.User + csrf string + token string } // webUser resolves the session cookie, or nil when the browser is not @@ -31,7 +32,10 @@ func (s *Server) webUser(r *http.Request) *webSession { if err != nil || c.Value == "" { return nil } - var out *webSession + var ( + out *webSession + seen time.Time + ) s.st.Read(func(st *store.State) { sess := st.Sessions[c.Value] if sess == nil || time.Now().After(sess.ExpiresAt) { @@ -39,9 +43,13 @@ func (s *Server) webUser(r *http.Request) *webSession { } if u := st.Users[sess.UserID]; u != nil && !u.Disabled { cp := *u - out = &webSession{user: &cp, csrf: sess.CSRF} + out = &webSession{user: &cp, csrf: sess.CSRF, token: c.Value} + seen = sess.LastSeenAt } }) + if out != nil { + s.markSeen(c.Value, seen) + } return out } @@ -115,7 +123,7 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) { if err != nil { return err } - token = s.newSession(st, u.ID, "web").Token + token = s.newBrowserSession(st, u.ID, r).Token return nil }) switch { @@ -187,36 +195,6 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, se "with the password you just set.") } -func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request, sess *webSession) { - next := r.FormValue("new_password") - if next != r.FormValue("new_password2") { - s.renderVaults(w, sess, "The two new passwords do not match.", "") - return - } - cookie, _ := r.Cookie(webCookie) - keep := "" - if cookie != nil { - keep = cookie.Value - } - err := s.st.Update(func(st *store.State) error { - return changePassword(st, sess.user.Username, r.FormValue("current_password"), - r.FormValue("totp"), next, keep) - }) - switch { - case errors.Is(err, errTOTPRequired): - s.renderVaults(w, sess, "Enter your one-time code to change the password.", "") - case errors.Is(err, errTOTPInvalid): - s.renderVaults(w, sess, "That one-time code was rejected.", "") - case errors.Is(err, errBadCredentials): - s.renderVaults(w, sess, "The current password is wrong.", "") - case err != nil: - s.renderVaults(w, sess, err.Error(), "") - default: - s.renderVaults(w, sess, "", - "Password changed. Other sessions on this account were signed out.") - } -} - // webDeleteVault is what the confirmation page shows before a vault goes. type webDeleteVault struct { ID string diff --git a/internal/server/webaccount.go b/internal/server/webaccount.go new file mode 100644 index 0000000..1255905 --- /dev/null +++ b/internal/server/webaccount.go @@ -0,0 +1,340 @@ +package server + +import ( + "errors" + "fmt" + "html/template" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "cc.tiredsysadmin.keeporg/internal/store" + "cc.tiredsysadmin.keeporg/internal/totp" +) + +// The settings page at /web/account: who you are, how you sign in, and every +// device and browser signed in as you, each of which can be signed out from +// here. The rules are the functions in account.go, shared with the API. + +// webAccount is what the settings page shows. +type webAccount struct { + Username string + Email string + ServerAdmin bool + Since string + Groups []webMembership + HasPassword bool + SSO string // the identity provider's host, when linked + TOTPOn bool + // TOTPKey and TOTPLink are set while an enrolment awaits its first code. + TOTPKey string + TOTPLink template.URL + Sessions []webSessionRow +} + +type webMembership struct { + Name string + Role string +} + +type webSessionRow struct { + ID string + Label string + Browser bool + Current bool + Created string + LastSeen string + Expires string +} + +func (s *Server) accountView(sess *webSession) webAccount { + var v webAccount + s.st.Read(func(st *store.State) { + u := st.Users[sess.user.ID] + if u == nil { + return + } + v = webAccount{ + Username: u.Username, Email: u.Email, ServerAdmin: u.ServerAdmin, + Since: u.CreatedAt.UTC().Format("2006-01-02"), + HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed, + } + if u.OIDCIssuer != "" { + v.SSO = u.OIDCIssuer + if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" { + v.SSO = iss.Host + } + } + if u.TOTPSecret != "" && !u.TOTPConfirmed { + v.TOTPKey = groupKey(u.TOTPSecret) + // The otpauth: scheme is what authenticator apps register for; + // html/template would neutralise it, and the URI is built here + // from our own values. + v.TOTPLink = template.URL(totp.URI("KeepOrg", u.Username, u.TOTPSecret)) + } + for _, m := range st.Memberships { + if m.UserID != u.ID { + continue + } + if g := st.Groups[m.GroupID]; g != nil { + v.Groups = append(v.Groups, webMembership{Name: g.Name, Role: string(m.Role)}) + } + } + sort.Slice(v.Groups, func(i, j int) bool { return v.Groups[i].Name < v.Groups[j].Name }) + for _, sv := range sessionsOf(st, u.ID, sess.token) { + v.Sessions = append(v.Sessions, webSessionRow{ + ID: sv.ID, Label: sv.Label, Browser: sv.Browser, Current: sv.Current, + Created: sv.CreatedAt.UTC().Format("2006-01-02 15:04 UTC"), + LastSeen: ago(sv.LastSeenAt), + Expires: sv.ExpiresAt.UTC().Format("2006-01-02"), + }) + } + }) + return v +} + +// groupKey spaces a base32 secret in fours, for typing into an app by hand. +func groupKey(k string) string { + var b strings.Builder + for i, r := range k { + if i > 0 && i%4 == 0 { + b.WriteByte(' ') + } + b.WriteRune(r) + } + return b.String() +} + +// ago says how long ago t was, coarsely. +func ago(t time.Time) string { + d := time.Since(t) + switch { + case d < 2*time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + case d < 2*time.Hour: + return "an hour ago" + case d < 24*time.Hour: + return fmt.Sprintf("%d hours ago", int(d.Hours())) + case d < 48*time.Hour: + return "yesterday" + case d < 30*24*time.Hour: + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + } + return t.UTC().Format("2006-01-02") +} + +func (s *Server) handleWebAccount(w http.ResponseWriter, r *http.Request, sess *webSession) { + s.renderAccount(w, sess, "", "") +} + +// handleWebAccountAction applies one control of the settings page. +func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) { + var notice string + err := s.st.Update(func(st *store.State) error { + switch r.FormValue("action") { + case "revoke": + if err := revokeSession(st, sess.user.ID, r.FormValue("session")); err != nil { + return err + } + notice = "That device is signed out. It has to sign in again to sync." + case "revoke-others": + n := revokeOtherSessions(st, sess.user.ID, sess.token) + notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions")) + case "password": + next := r.FormValue("new_password") + if next != r.FormValue("new_password2") { + return errors.New("the two new passwords do not match") + } + if err := changePassword(st, sess.user.Username, r.FormValue("current_password"), + r.FormValue("totp"), next, sess.token); err != nil { + return err + } + notice = "Password changed. Your other sessions were signed out." + case "totp-begin": + if _, err := beginTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil { + return err + } + notice = "Add the key below to your authenticator app, then enter the code it shows." + case "totp-confirm": + if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil { + return err + } + notice = "Two-factor authentication is on. Signing in now asks for a one-time code." + case "totp-cancel": + cancelTOTP(st, sess.user.ID) + notice = "Setup cancelled. Nothing changed." + case "totp-disable": + if err := disableTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil { + return err + } + notice = "Two-factor authentication is off." + default: + return errBadAction + } + return nil + }) + if err != nil { + s.renderAccount(w, sess, accountError(err), "") + return + } + // Signing out one's own browser from the list ends this session too. + if s.webUser(r) == nil { + s.setWebCookie(w, "", -time.Second) + http.Redirect(w, r, "/web", http.StatusFound) + return + } + s.renderAccount(w, sess, "", notice) +} + +func accountError(err error) string { + switch { + case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired): + return "Confirm with your password and your current one-time code." + case errors.Is(err, errTOTPInvalid): + return "That one-time code was rejected. Check the clock on the device that shows it." + case errors.Is(err, errBadCredentials): + return "The password is wrong." + case errors.Is(err, errNoSuchSession): + return "That session is already signed out." + } + return humanError(err) +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +func (s *Server) renderAccount(w http.ResponseWriter, sess *webSession, errMsg, notice string) { + d := s.shell(sess, "Account", "Account") + d["A"], d["Error"], d["Notice"] = s.accountView(sess), errMsg, notice + render(w, accountTmpl, d) +} + +var accountTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}} +

Account

+

How you sign in, and everywhere you are signed in.

+{{if .Error}}

{{.Error}}

{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} +{{with .A}} +
You + + +{{if .Email}}{{end}} + + + + +
Username{{.Username}}
Email{{.Email}}
Signs in with + {{if .HasPassword}}password{{end}} + {{if .TOTPOn}}one-time code{{end}} + {{if .SSO}}single sign-on via {{.SSO}}{{end}} +
Role{{if .ServerAdmin}}server administrator{{else}}member{{end}}
Groups{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} ({{$g.Role}}){{else}}none{{end}}
Member since{{.Since}}
+
+ +
Signed-in devices +

Every browser and KeepOrg client signed in as you. Signing one out +stops it syncing at once; it has to sign in again.

+
+ + +{{range .Sessions}} + + + + + +{{end}} +
DeviceLast activeSigned inExpires
{{if .Label}}{{.Label}}{{else}}unnamed{{end}}
+ {{if .Browser}}browser{{else}}app{{end}} + {{if .Current}}this browser{{end}}
{{.LastSeen}}{{.Created}}{{.Expires}}{{if not .Current}} + + + + + {{end}}
+{{if gt (len .Sessions) 1}} +
+ + + +
+{{end}} +
+ +{{if .HasPassword}} +
Two-factor authentication +{{if .TOTPOn}} +

On. Signing in with your password also asks for a one-time code +from your authenticator app.

+
+ +
+
+
+
+
+
+
+ + +
+
+{{else if .TOTPKey}} +

Add this key to an authenticator app such as Aegis, 2FAS or +Google Authenticator, then enter the six-digit code it shows.

+

{{.TOTPKey}}

+

Open in an authenticator app on this device

+
+ + + + + +
+
+ + + +
+{{else}} +

Off. With it on, a stolen password is not enough to sign in: +each sign-in also needs a code from an app on your phone.

+
+ + + +
+{{end}} +
+ +
Password +

The password you sign in with, not any vault's password. +Changing it signs out your other devices.

+
+ + + + +{{if .TOTPOn}} +{{end}} + + + + + +
+
+{{else}} +

You sign in through single sign-on{{if .SSO}} at {{.SSO}}{{end}}, +so your password and second factor are managed there, not here.

+{{end}} +{{end}} +{{end}}`)) diff --git a/internal/server/webadmin.go b/internal/server/webadmin.go index b07a879..46ed4a3 100644 --- a/internal/server/webadmin.go +++ b/internal/server/webadmin.go @@ -55,6 +55,7 @@ func (s *Server) shell(sess *webSession, title, current string) map[string]any { navItem{Label: "People", Href: "/web/users"}, navItem{Label: "Invites", Href: "/web/invites"}) } + nav = append(nav, navItem{Label: "Account", Href: "/web/account"}) for i := range nav { nav[i].Current = nav[i].Label == current } @@ -403,6 +404,8 @@ func accountNotice(username string, act accountAction) string { return username + " is no longer a server administrator." case accountClearTOTP: return username + " can sign in without a one-time code and should enrol a new one." + case accountSignOut: + return username + " is signed out on every device and browser." case accountDelete: return username + " was deleted, along with their group memberships and sessions." } diff --git a/internal/store/model.go b/internal/store/model.go index 00e4a67..b8920d5 100644 --- a/internal/store/model.go +++ b/internal/store/model.go @@ -145,11 +145,21 @@ type Session struct { UserID string `json:"user_id"` Label string `json:"label,omitempty"` // CSRF guards the browser forms of a session held in a cookie. - CSRF string `json:"csrf,omitempty"` + CSRF string `json:"csrf,omitempty"` + // Browser marks a session held in a web cookie rather than by a client + // application. Sessions from before the field have the label "web". + Browser bool `json:"browser,omitempty"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` + // LastSeenAt is when the session last made a request, to a resolution + // of a few minutes: recording every request would rewrite the state + // document on each one. + LastSeenAt time.Time `json:"last_seen_at,omitempty"` } +// IsBrowser reports whether the session belongs to a web browser. +func (s *Session) IsBrowser() bool { return s.Browser || s.Label == "web" } + // DeviceAuth backs the browser-assisted login used for OIDC on desktop and // Android: the client shows a short user code, the browser approves it, the // client polls until a session token appears. diff --git a/packaging/deb/server/changelog b/packaging/deb/server/changelog index 91a2d06..f4771fb 100644 --- a/packaging/deb/server/changelog +++ b/packaging/deb/server/changelog @@ -1,3 +1,15 @@ +keeporgd (0.10.0) unstable; urgency=medium + + * New Account page in the web interface: every signed-in browser and + client with a button to sign each out, or all but the current one; + two-factor setup, replacement and removal; the password form, moved + from the vault page. Matching API: GET and DELETE + /api/v1/auth/sessions, DELETE /api/v1/auth/totp. Replacing or removing + an active second factor now needs the password and a current code. + Administrators can sign a user out everywhere. + + -- Azer Abdullaev Mon, 02 Nov 2026 13:27:12 +0100 + keeporgd (0.9.0) unstable; urgency=medium * A client starting a browser sign-in may pass return_url, a keeporg: diff --git a/versions.mk b/versions.mk index d61876e..e55cbc1 100644 --- a/versions.mk +++ b/versions.mk @@ -1,6 +1,6 @@ # Single source of truth for all platform versions (semver). # Bump with: make bump PLATFORM= PART= -VERSION_SERVER := 0.9.0 +VERSION_SERVER := 0.10.0 VERSION_LINUX := 0.5.0 VERSION_MACOS := 0.5.0 VERSION_WINDOWS := 0.5.0