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

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

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

Server version 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 15:37:47 +01:00
co-authored by Claude Opus 5.5
parent 318bd68908
commit f8f634fbf7
17 changed files with 1086 additions and 126 deletions
+33 -5
View File
@@ -436,11 +436,12 @@ browsing, by design.
| Page | What it does | | 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/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` | 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/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 | | `/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 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 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. 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 **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 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 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. `targetSdk` is its own change.
- **No key-file or Yubikey handling**, and none needed: those live in the - **No key-file or Yubikey handling**, and none needed: those live in the
KeePass application. KeePass application.
- **Sessions are not listable or revocable individually** beyond - **No QR code for two-factor setup.** The account page shows the key and
`keeporg logout` on the device itself. Adding `GET`/`DELETE an `otpauth:` link, which a phone opens straight into its authenticator;
/api/v1/auth/sessions` is a small, well-bounded job. 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 - **Android does not restart its watcher at boot.** Add a
`RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission `RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission
it costs. it costs.
+11 -1
View File
@@ -21,13 +21,23 @@ https, and every form carries a per-session CSRF token.
| POST | `/api/v1/auth/logout` | – | revokes the calling token | | POST | `/api/v1/auth/logout` | – | revokes the calling token |
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships | | 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/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}` | | 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/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` | | 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 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 `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 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 "Return to KeepOrg" link to it. Only the `keeporg:` scheme is accepted, so
+316
View File
@@ -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
}
+276
View File
@@ -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
}
+5
View File
@@ -324,6 +324,7 @@ const (
accountPromote accountAction = "promote" accountPromote accountAction = "promote"
accountDemote accountAction = "demote" accountDemote accountAction = "demote"
accountClearTOTP accountAction = "clear-totp" accountClearTOTP accountAction = "clear-totp"
accountSignOut accountAction = "sign-out"
accountDelete accountAction = "delete" accountDelete accountAction = "delete"
) )
@@ -361,6 +362,10 @@ func adminAccount(st *store.State, u *store.User, ref string, act accountAction)
target.ServerAdmin = false target.ServerAdmin = false
case accountClearTOTP: case accountClearTOTP:
target.TOTPSecret, target.TOTPConfirmed = "", false 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: case accountDelete:
delete(st.Users, target.ID) delete(st.Users, target.ID)
revokeSessions(st, target.ID) revokeSessions(st, target.ID)
-48
View File
@@ -136,54 +136,6 @@ func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.U
writeJSON(w, http.StatusOK, out) 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 ---- // ---- device login: the browser approves, the client polls ----
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -248,15 +248,15 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
return err return err
} }
dev.OIDCState = "" dev.OIDCState = ""
token := s.newSession(st, u.ID, dev.Label).Token
if dev.Web { if dev.Web {
token := s.newBrowserSession(st, u.ID, r).Token
// Nothing is waiting to poll for this one; the browser in front // Nothing is waiting to poll for this one; the browser in front
// of us is the client. // of us is the client.
webToken = token webToken = token
delete(st.Devices, dev.DeviceCode) delete(st.Devices, dev.DeviceCode)
return nil return nil
} }
dev.SessionToken = token dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
returnURL = dev.ReturnURL returnURL = dev.ReturnURL
return nil return nil
}); err != nil { }); err != nil {
+3 -17
View File
@@ -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)} background:transparent;color:var(--accent);border:1px solid var(--line)}
td button.danger,a.danger{color:#b02a37} td button.danger,a.danger{color:#b02a37}
button.destroy{background:#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} @media(prefers-color-scheme:dark){td button.danger,a.danger{color:#f2b8c0}
button.destroy{background:#a3313c}} button.destroy{background:#a3313c}}
.inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0} .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.</p> vault. Ask an administrator to add you to one.</p>
{{end}} {{end}}
<details> <p class="note">Your password, two-factor authentication and signed-in devices
<summary>Change your account password</summary> are on the <a href="/web/account">Account</a> page.</p>
<p class="sub">This is the password you sign in with, not any vault's password.
Changing it signs out your other sessions.</p>
<form method="post" action="/web/password">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
<label for="cp_totp">One-time code <small>(if enabled)</small></label>
<input id="cp_totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="10" required>
<label for="new_password2">Repeat new password</label>
<input id="new_password2" name="new_password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Change password</button>
</form>
</details>
{{end}}`)) {{end}}`))
var deleteVaultTmpl = template.Must(template.Must(pages.Clone()).Parse(` var deleteVaultTmpl = template.Must(template.Must(pages.Clone()).Parse(`
+6
View File
@@ -216,6 +216,12 @@ groups you administer.{{end}}</p>
</form> </form>
{{end}} {{end}}
{{if not .Self}} {{if not .Self}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="sign-out">
<button type="submit">Sign out everywhere</button>
</form>
<form method="post" action="/web/users"> <form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}"> <input type="hidden" name="user" value="{{.ID}}">
+7 -7
View File
@@ -95,25 +95,25 @@ func TestWebChangesThePassword(t *testing.T) {
b := newBrowser(t, h) b := newBrowser(t, h)
csrf := b.signIn() csrf := b.signIn()
// The form is on the page, and it is guarded like the others. // The form is on the account page, and it is guarded like the others.
_, page := b.get("/web/vaults") _, page := b.get("/web/account")
if !strings.Contains(page, "Change your account password") { if !strings.Contains(page, "Change password") {
t.Fatalf("no password form on the vault page:\n%s", page) 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}, "current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {newAdminPassword}}); status != http.StatusForbidden { "new_password2": {newAdminPassword}}); status != http.StatusForbidden {
t.Fatalf("a form without a CSRF token returned %d", status) 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}, "current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {"something-else"}}) "new_password2": {"something-else"}})
if !strings.Contains(page, "do not match") { if !strings.Contains(page, "do not match") {
t.Fatalf("mismatch not reported:\n%s", page) 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}, "current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {newAdminPassword}}) "new_password2": {newAdminPassword}})
if !strings.Contains(page, "Password changed") { if !strings.Contains(page, "Password changed") {
+43 -5
View File
@@ -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/password", s.authed(s.handleChangePassword))
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll)) 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("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/start", s.handleDeviceStart)
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) 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("POST /web/signout", s.webForm(s.handleWebSignOut))
m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults)) m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults))
m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault)) 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("GET /web/vaults/{id}/delete", s.webPage(s.handleWebDeleteVaultPage))
m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault)) m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault))
m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups)) 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 == "" { if tok == "" {
return nil, errUnauthorized return nil, errUnauthorized
} }
var out *store.User var (
out *store.User
seen time.Time
)
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
sess := st.Sessions[tok] sess := st.Sessions[tok]
if sess == nil || time.Now().After(sess.ExpiresAt) { 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 { if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
cp := *u cp := *u
out = &cp out, seen = &cp, sess.LastSeenAt
} }
}) })
if out == nil { if out == nil {
return nil, errUnauthorized return nil, errUnauthorized
} }
s.markSeen(tok, seen)
return out, nil 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 // bearer extracts a token from the Authorization header, or from the
// access_token query parameter, which EventSource clients need because they // access_token query parameter, which EventSource clients need because they
// cannot set headers. // 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 { func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
now := time.Now()
sess := &store.Session{ sess := &store.Session{
Token: secret.Token(), Token: secret.Token(),
UserID: userID, UserID: userID,
Label: label, Label: label,
CSRF: secret.Token(), CSRF: secret.Token(),
CreatedAt: time.Now(), CreatedAt: now,
ExpiresAt: time.Now().Add(s.cfg.SessionTTL), ExpiresAt: now.Add(s.cfg.SessionTTL),
LastSeenAt: now,
} }
st.Sessions[sess.Token] = sess st.Sessions[sess.Token] = sess
return 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) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
var users, dbs int var users, dbs int
s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) }) s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) })
+11 -33
View File
@@ -22,6 +22,7 @@ const webCookie = "keeporg_web"
type webSession struct { type webSession struct {
user *store.User user *store.User
csrf string csrf string
token string
} }
// webUser resolves the session cookie, or nil when the browser is not // 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 == "" { if err != nil || c.Value == "" {
return nil return nil
} }
var out *webSession var (
out *webSession
seen time.Time
)
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
sess := st.Sessions[c.Value] sess := st.Sessions[c.Value]
if sess == nil || time.Now().After(sess.ExpiresAt) { 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 { if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
cp := *u 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 return out
} }
@@ -115,7 +123,7 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
return err return err
} }
token = s.newSession(st, u.ID, "web").Token token = s.newBrowserSession(st, u.ID, r).Token
return nil return nil
}) })
switch { switch {
@@ -187,36 +195,6 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, se
"with the password you just set.") "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. // webDeleteVault is what the confirmation page shows before a vault goes.
type webDeleteVault struct { type webDeleteVault struct {
ID string ID string
+340
View File
@@ -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"}}
<h1>Account</h1>
<p class="sub">How you sign in, and everywhere you are signed in.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{with .A}}
<fieldset><legend>You</legend>
<table><tbody>
<tr><th>Username</th><td class="name">{{.Username}}</td></tr>
{{if .Email}}<tr><th>Email</th><td>{{.Email}}</td></tr>{{end}}
<tr><th>Signs in with</th><td>
{{if .HasPassword}}<span class="tag">password</span>{{end}}
{{if .TOTPOn}}<span class="tag">one-time code</span>{{end}}
{{if .SSO}}<span class="tag">single sign-on</span> <small>via {{.SSO}}</small>{{end}}
</td></tr>
<tr><th>Role</th><td>{{if .ServerAdmin}}server administrator{{else}}member{{end}}</td></tr>
<tr><th>Groups</th><td>{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} <small>({{$g.Role}})</small>{{else}}none{{end}}</td></tr>
<tr><th>Member since</th><td>{{.Since}}</td></tr>
</tbody></table>
</fieldset>
<fieldset><legend>Signed-in devices</legend>
<p class="sub">Every browser and KeepOrg client signed in as you. Signing one out
stops it syncing at once; it has to sign in again.</p>
<div class="scroll"><table>
<thead><tr><th>Device</th><th>Last active</th><th>Signed in</th><th>Expires</th><th></th></tr></thead>
<tbody>
{{range .Sessions}}<tr>
<td class="name">{{if .Label}}{{.Label}}{{else}}unnamed{{end}}<br>
<span class="tag">{{if .Browser}}browser{{else}}app{{end}}</span>
{{if .Current}}<span class="tag">this browser</span>{{end}}</td>
<td>{{.LastSeen}}</td>
<td>{{.Created}}</td>
<td>{{.Expires}}</td>
<td>{{if not .Current}}<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="session" value="{{.ID}}">
<button class="danger" type="submit">Sign out</button>
</form>{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{if gt (len .Sessions) 1}}
<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="revoke-others">
<button class="destroy" type="submit">Sign out every other device</button>
</form>
{{end}}
</fieldset>
{{if .HasPassword}}
<fieldset><legend>Two-factor authentication</legend>
{{if .TOTPOn}}
<p class="sub">On. Signing in with your password also asks for a one-time code
from your authenticator app.</p>
<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<div class="inline">
<div><label for="tf_password">Password</label>
<input id="tf_password" name="password" type="password" autocomplete="current-password" required></div>
<div><label for="tf_totp">Current one-time code</label>
<input id="tf_totp" name="totp" inputmode="numeric" autocomplete="one-time-code" required></div>
</div>
<div class="inline">
<button type="submit" name="action" value="totp-begin">Move to a new authenticator</button>
<button class="destroy" type="submit" name="action" value="totp-disable">Turn off</button>
</div>
</form>
{{else if .TOTPKey}}
<p class="sub">Add this key to an authenticator app such as Aegis, 2FAS or
Google Authenticator, then enter the six-digit code it shows.</p>
<p><code>{{.TOTPKey}}</code></p>
<p><a href="{{.TOTPLink}}">Open in an authenticator app on this device</a></p>
<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="totp-confirm">
<label for="code">Code from the app</label>
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
<button type="submit">Turn on</button>
</form>
<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="totp-cancel">
<button class="secondary" type="submit">Cancel</button>
</form>
{{else}}
<p class="sub">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.</p>
<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="totp-begin">
<button type="submit">Set up two-factor authentication</button>
</form>
{{end}}
</fieldset>
<fieldset><legend>Password</legend>
<p class="sub">The password you sign in with, not any vault's password.
Changing it signs out your other devices.</p>
<form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="password">
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
{{if .TOTPOn}}<label for="cp_totp">One-time code</label>
<input id="cp_totp" name="totp" inputmode="numeric" autocomplete="one-time-code" required>{{end}}
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="10" required>
<label for="new_password2">Repeat new password</label>
<input id="new_password2" name="new_password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Change password</button>
</form>
</fieldset>
{{else}}
<p class="note">You sign in through single sign-on{{if .SSO}} at {{.SSO}}{{end}},
so your password and second factor are managed there, not here.</p>
{{end}}
{{end}}
{{end}}`))
+3
View File
@@ -55,6 +55,7 @@ func (s *Server) shell(sess *webSession, title, current string) map[string]any {
navItem{Label: "People", Href: "/web/users"}, navItem{Label: "People", Href: "/web/users"},
navItem{Label: "Invites", Href: "/web/invites"}) navItem{Label: "Invites", Href: "/web/invites"})
} }
nav = append(nav, navItem{Label: "Account", Href: "/web/account"})
for i := range nav { for i := range nav {
nav[i].Current = nav[i].Label == current 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." return username + " is no longer a server administrator."
case accountClearTOTP: case accountClearTOTP:
return username + " can sign in without a one-time code and should enrol a new one." 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: case accountDelete:
return username + " was deleted, along with their group memberships and sessions." return username + " was deleted, along with their group memberships and sessions."
} }
+10
View File
@@ -146,10 +146,20 @@ type Session struct {
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
// CSRF guards the browser forms of a session held in a cookie. // 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"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_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 // 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 // Android: the client shows a short user code, the browser approves it, the
// client polls until a session token appears. // client polls until a session token appears.
+12
View File
@@ -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 <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 13:27:12 +0100
keeporgd (0.9.0) unstable; urgency=medium keeporgd (0.9.0) unstable; urgency=medium
* A client starting a browser sign-in may pass return_url, a keeporg: * A client starting a browser sign-in may pass return_url, a keeporg:
+1 -1
View File
@@ -1,6 +1,6 @@
# Single source of truth for all platform versions (semver). # Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch> # Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.9.0 VERSION_SERVER := 0.10.0
VERSION_LINUX := 0.5.0 VERSION_LINUX := 0.5.0
VERSION_MACOS := 0.5.0 VERSION_MACOS := 0.5.0
VERSION_WINDOWS := 0.5.0 VERSION_WINDOWS := 0.5.0