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}} -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}} +