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