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