From 4276892590707cebc30c0853e19a01219e594868 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 19:43:49 +0200 Subject: [PATCH] feat(auth): see where you are signed in, and end one of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `last_seen_at` has been written on every request since sessions existed — throttled to once a minute, deliberately, so a list could one day say "denne enhed, lige nu" — and nothing has ever read it. The store even had ListSessions. What was missing was two routes and a panel. GET /auth/sessions lists them, most recently used first, with the current one marked so nobody has to work out which row is the browser they are reading it in. DELETE /auth/sessions/{id} ends one. Ending the current session is allowed and is simply logging out: refusing it would be a rule with no reason behind it, and somebody clearing every device wants this one gone too. Both are behind requireSession rather than the ambient auth, the same as the API tokens. A leaked token that could list its owner's devices and end their sessions turns a theft into a lockout. The delete is scoped in the statement rather than checked and then run. An id read in one step and deleted in the next is a window, and the whole reason somebody is on this screen is that they think another person is in their account. The device names are a handful of substring tests, not a parsing library. The question this answers is "is one of these me?", and for that a wrong guess between two Chromium forks costs nothing while a dependency that has to be kept current costs something every year. Order is the whole correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome, and Chrome says Safari. The raw string is sent alongside and sits in the row's title, so anything the summary gets wrong is still visible — and something that is not a browser at all shows its own name rather than "Ukendt", because a CalDAV client is a real session. Times are relative. The question is "was that me, just now?", and an absolute timestamp makes the reader do the subtraction; past a week it switches to a date, because "for 23 dage siden" is a number nobody can place. The smoke test signs in a second time in a second browser context, ends that session from the first, and checks the other browser is really signed out — and that this one is not. Co-Authored-By: Claude Opus 5 --- README.md | 2 + docs/openapi.yaml | 59 +++++++++ internal/httpapi/auth_test.go | 134 ++++++++++++++++++++ internal/httpapi/server.go | 9 ++ internal/httpapi/sessions_handlers.go | 147 ++++++++++++++++++++++ internal/store/sessions.go | 17 +++ web/e2e/auth.setup.js | 6 +- web/e2e/smoke.spec.js | 42 +++++++ web/e2e/user.js | 13 ++ web/src/lib/api.js | 3 + web/src/routes/indstillinger/+page.svelte | 129 +++++++++++++++++++ 11 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 internal/httpapi/sessions_handlers.go create mode 100644 web/e2e/user.js diff --git a/README.md b/README.md index 5c78547..f09ceaf 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector. | Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done | | Task detail: description, sub-tasks, comments, files, reminders | Done | | Settings: account, notifications, integrations, AI, tokens, data | Done | +| Device list: where you are signed in, and signing one out | Done, tested | +| Invite and password-reset links that open the right page | Done, smoke-tested | | Drag to reorder, in both the list and the board | Done | | Drag a task to another project or another day | Done, smoke-tested | | Foldable project groups in the sidebar | Done, tested | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5964bba..44205e8 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -351,6 +351,44 @@ paths: "204": { description: Off. } "422": { $ref: "#/components/responses/Validation" } + /api/v1/auth/sessions: + get: + tags: [auth] + summary: Where you are signed in. + description: | + Most recently used first. `id` is the stored hash of the cookie and never + the cookie itself — it cannot be pasted into a browser, and it is what the + delete below takes. + + Session only: an API token cannot list the devices of the person it was + stolen from. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + sessions: + type: array + items: { $ref: "#/components/schemas/Session" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/auth/sessions/{sessionID}: + parameters: + - { name: sessionID, in: path, required: true, schema: { type: string } } + delete: + tags: [auth] + summary: Sign one device out. + description: | + Ending the current session is allowed and is simply logging out — + somebody clearing every device wants this one gone too. + responses: + "204": { description: Ended. } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + /api/v1/auth/recovery-codes: get: tags: [auth] @@ -2232,6 +2270,27 @@ components: type: array items: { type: string } + Session: + type: object + properties: + id: { type: string } + current: + type: boolean + description: The one making this request. + device: + type: string + description: | + A summary of the user agent — "Chrome på macOS". A handful of + substring tests rather than a parsing library: the question it + answers is "is one of these me?", and the raw string is sent beside + it so anything the summary gets wrong is still visible. + example: Chrome på macOS + user_agent: { type: string } + ip: { type: string } + created_at: { type: string, format: date-time } + last_seen_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time } + Task: type: object properties: diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 5a1e73f..633510c 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -761,3 +761,137 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) { t.Errorf("status %d, want 401", resp.StatusCode) } } + +// last_seen_at has been written on every request since sessions existed, for +// exactly this list, and nothing read it. A session you cannot see is a session +// you cannot end. +func TestSessionsCanBeListedAndEnded(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // A second sign-in from "another device": same account, its own cookie jar. + other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)} + resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("second login: status %d", resp.StatusCode) + } + + resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list sessions: %d %v", resp.StatusCode, body) + } + sessions, _ := body["sessions"].([]any) + if len(sessions) != 2 { + t.Fatalf("want two sessions, got %v", sessions) + } + + // Exactly one is this one, or the interface cannot tell somebody which row is + // the browser they are reading it in. + current := "" + for _, raw := range sessions { + s := raw.(map[string]any) + if s["current"] == true { + if current != "" { + t.Fatal("two sessions both claim to be the current one") + } + current = s["id"].(string) + } + if s["device"] == "" { + t.Errorf("no device summary: %v", s) + } + if s["last_seen_at"] == nil { + t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s) + } + } + if current == "" { + t.Fatal("none of the sessions is marked as the current one") + } + + // End the other one. + var otherID string + for _, raw := range sessions { + if s := raw.(map[string]any); s["id"] != current { + otherID = s["id"].(string) + } + } + if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("delete session: status %d", resp.StatusCode) + } + + // That device is signed out, and this one is not. + if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized { + t.Errorf("the ended session still works: status %d", r.StatusCode) + } + if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK { + t.Errorf("ending another session logged this one out: status %d", r.StatusCode) + } +} + +// Somebody else's session is not yours to end, even with its id in hand. +func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + + _, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil) + mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string) + + if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound { + t.Errorf("status %d, want 404", resp.StatusCode) + } + // And it still works. + if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK { + t.Errorf("somebody else ended my session: status %d", r.StatusCode) + } +} + +// An API token is accepted almost everywhere, and must not be accepted here: a +// leaked token that could end its owner's sessions, or even list their devices, +// turns a theft into a lockout. +func TestSessionsAreNotReachableWithAToken(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + token := ts.apiToken(t, user.ID) + + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := (&http.Client{}).Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status %d, want 403", resp.StatusCode) + } +} + +func TestUserAgentsAreSummarisedForAPerson(t *testing.T) { + cases := []struct { + ua string + want string + }{ + {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"}, + // An iPhone's user agent says Mac OS X too, so the order of the tests is + // the whole correctness of this function. + {"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"}, + // And Edge claims to be Chrome, which claims to be Safari. + {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"}, + {"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"}, + {"", "Ukendt enhed"}, + // Not a browser at all. Its own name beats a word that says the server + // gave up: a CalDAV client is a real session. + {"curl/8.4.0", "curl/8.4.0"}, + } + for _, c := range cases { + if got := describeUserAgent(c.ua); got != c.want { + t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want) + } + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 007b78c..aaea91a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -151,6 +151,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Post("/totp/disable", s.handleTOTPDisable) r.Get("/recovery-codes", s.handleRecoveryCodesCount) r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate) + + // Behind requireSession, like the API tokens and for the same + // reason: a leaked token must not be able to end the sessions of + // the person it was stolen from, or list their devices. + r.Group(func(r chi.Router) { + r.Use(s.requireSession) + r.Get("/sessions", s.handleListSessions) + r.Delete("/sessions/{sessionID}", s.handleDeleteSession) + }) }) }) diff --git a/internal/httpapi/sessions_handlers.go b/internal/httpapi/sessions_handlers.go new file mode 100644 index 0000000..9891236 --- /dev/null +++ b/internal/httpapi/sessions_handlers.go @@ -0,0 +1,147 @@ +package httpapi + +import ( + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +// Where a person is signed in, and how to end one of them. +// +// `last_seen_at` has been written on every request since sessions existed — +// throttled to once a minute, precisely so this list could say "this device, 2 +// minutes ago" — and nothing has ever read it. A session you cannot see is a +// session you cannot end. + +type sessionJSON struct { + ID string `json:"id"` + // Current marks the one making the request, so the interface can say "denne + // enhed" rather than making somebody work it out from a user agent. + Current bool `json:"current"` + // Device is a summary; UserAgent is what it was summarised from. Both, because + // the summary is what you read and the original is what settles the question + // when the summary is not enough. + Device string `json:"device"` + UserAgent string `json:"user_agent"` + IP string `json:"ip"` + CreatedAt string `json:"created_at"` + LastSeenAt string `json:"last_seen_at"` + ExpiresAt string `json:"expires_at"` +} + +// handleListSessions lists the caller's live sessions, most recently used first. +// +// The id here is the stored hash of the cookie, never the cookie itself: it cannot +// be pasted into a browser, and it is what the delete below needs. +func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + current := sessionFrom(r.Context()) + + sessions, err := s.db.ListSessions(r.Context(), user.ID) + if err != nil { + s.internal(w, "list sessions", err) + return + } + + out := make([]sessionJSON, 0, len(sessions)) + for _, sess := range sessions { + out = append(out, sessionJSON{ + ID: sess.ID, + Current: current != nil && sess.ID == current.ID, + Device: describeUserAgent(sess.UA), + UserAgent: sess.UA, + IP: sess.IP, + CreatedAt: sess.CreatedAt.Format(time.RFC3339), + LastSeenAt: sess.LastSeenAt.Format(time.RFC3339), + ExpiresAt: sess.ExpiresAt.Format(time.RFC3339), + }) + } + writeJSON(w, http.StatusOK, map[string]any{"sessions": out}) +} + +// handleDeleteSession signs one device out. Ending the current one is allowed and +// is simply logging out — refusing it would be a rule with no reason behind it, +// and somebody clearing every device wants this one gone too. +func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + sessionID := chi.URLParam(r, "sessionID") + + if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil { + s.storeError(w, "delete session", err) + return + } + if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID { + s.clearSessionCookie(w) + } + w.WriteHeader(http.StatusNoContent) +} + +// describeUserAgent turns a user-agent string into something a person recognises. +// +// Deliberately a handful of substring tests rather than a parsing library. The +// question this answers is "is one of these me?", and for that a wrong guess +// between two Chromium forks costs nothing while a dependency that has to be kept +// current costs something every year. The raw string is sent alongside, so +// anything this gets wrong is still visible. +// +// Order matters: every browser's user agent claims to be several other browsers, +// so the most specific name has to be tested first. +func describeUserAgent(ua string) string { + if strings.TrimSpace(ua) == "" { + return "Ukendt enhed" + } + + browser := "" + switch { + case strings.Contains(ua, "Edg/"): + browser = "Edge" + case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"): + browser = "Opera" + case strings.Contains(ua, "Firefox/"): + browser = "Firefox" + case strings.Contains(ua, "Chrome/"): + browser = "Chrome" + case strings.Contains(ua, "Safari/"): + browser = "Safari" + } + + system := "" + switch { + // Before "Mac OS X": an iPhone's user agent says both. + case strings.Contains(ua, "iPhone"): + system = "iPhone" + case strings.Contains(ua, "iPad"): + system = "iPad" + case strings.Contains(ua, "Android"): + system = "Android" + case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"): + system = "macOS" + case strings.Contains(ua, "Windows"): + system = "Windows" + case strings.Contains(ua, "Linux"): + system = "Linux" + } + + switch { + case browser != "" && system != "": + return browser + " på " + system + case browser != "": + return browser + case system != "": + return system + } + // Nothing recognised: show the string itself rather than "Ukendt". A CalDAV + // client or a script is a real session, and its own name is more useful than a + // word that says the server gave up. + return truncateRunes(ua, 60) +} + +func truncateRunes(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) + "…" +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 0f88fd3..c8b0fe5 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -123,6 +123,23 @@ func (db *DB) DeleteSession(ctx context.Context, sessionID string) error { return err } +// DeleteUserSession ends one session, and only if it belongs to the caller. +// +// Scoped in the statement rather than checked first: an id read and then deleted +// in two steps is a window, and the whole point of this call is that somebody is +// using it because they think another person is in their account. +func (db *DB) DeleteUserSession(ctx context.Context, sessionID, userID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error { _, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) return err diff --git a/web/e2e/auth.setup.js b/web/e2e/auth.setup.js index 6d3bfc8..81f6d26 100644 --- a/web/e2e/auth.setup.js +++ b/web/e2e/auth.setup.js @@ -7,11 +7,7 @@ import { test as setup, expect } from '@playwright/test'; * or the app shell is broken, nothing below runs and the failure names this file. */ -export const USER = { - name: 'Kristian', - email: 'test@example.dk', - password: 'et langt kodeord til test' -}; +import { USER } from './user.js'; const AUTH_FILE = 'e2e/.auth/user.json'; diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 9b46b08..eb22939 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { USER } from './user.js'; /** * The four flows a broken build must not survive: add a task, tick it off, reach @@ -472,6 +473,47 @@ test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browse await context.close(); }); +/** + * The device list, and ending one of them. + * + * `last_seen_at` has been written on every request since sessions existed — once + * a minute, deliberately, so this list could say "lige nu" — and until now + * nothing read it. A session you cannot see is a session you cannot end, which + * matters most in the one situation it exists for: somebody else has your cookie. + */ +test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ browser, page }) => { + const trouble = watchForTrouble(page); + + // A second sign-in as the same person: a real second session, not a second tab + // sharing this one's cookie. + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const elsewhere = await context.newPage(); + await elsewhere.goto('/'); + await elsewhere.getByLabel('E-mail').fill(USER.email); + await elsewhere.getByLabel('Adgangskode').fill(USER.password); + await elsewhere.getByRole('button', { name: 'Log ind' }).click(); + await expect(elsewhere.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + + await page.goto('/indstillinger'); + const panel = page.locator('section.panel').filter({ hasText: 'Enheder' }); + await expect(panel.getByText('denne enhed')).toBeVisible(); + await expect(panel.locator('li')).toHaveCount(2); + + // End the one that is not this browser — the row without the badge. + await panel.locator('li').filter({ hasNot: page.getByText('denne enhed') }) + .getByRole('button', { name: 'Log ud' }) + .click(); + await expect(panel.locator('li')).toHaveCount(1); + + // That session is really gone, and this one is untouched. + await elsewhere.reload(); + await expect(elsewhere.getByText('Log ind for at fortsætte')).toBeVisible(); + await context.close(); + + await expect(panel.getByText('denne enhed')).toBeVisible(); + expect(trouble).toEqual([]); +}); + /** * The MCP connector address must not fall through to the app shell. * diff --git a/web/e2e/user.js b/web/e2e/user.js new file mode 100644 index 0000000..9f9c5c8 --- /dev/null +++ b/web/e2e/user.js @@ -0,0 +1,13 @@ +/** + * The account every test runs as. + * + * In its own file rather than exported from auth.setup.js, because Playwright + * refuses to let one test file import another — and both the setup that creates + * this account and the device-list test that signs in as it a second time need + * the same credentials. + */ +export const USER = { + name: 'Kristian', + email: 'test@example.dk', + password: 'et langt kodeord til test' +}; diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 1b1eba5..cd78a2d 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -167,6 +167,9 @@ export const api = { totpSetup: () => post('/auth/totp/setup'), totpConfirm: (code) => post('/auth/totp/confirm', { code }), totpDisable: (password) => post('/auth/totp/disable', { password }), + listSessions: () => get('/auth/sessions'), + endSession: (id) => del(`/auth/sessions/${id}`), + recoveryCodes: () => get('/auth/recovery-codes'), regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }), diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte index ce6fbaa..9cee9b8 100644 --- a/web/src/routes/indstillinger/+page.svelte +++ b/web/src/routes/indstillinger/+page.svelte @@ -144,6 +144,54 @@ if (!Object.keys(totpErrors).length) app.toast(humanMessage(e)); } } + + // --- sessions -------------------------------------------------------------- + + let sessions = $state([]); + + $effect(() => { + api.listSessions().then((r) => (sessions = r.sessions)).catch(() => {}); + }); + + async function endSession(session) { + if (session.current) { + if (!confirm('Det er denne enhed. Du bliver logget ud.')) return; + await api.endSession(session.id); + // A full reload rather than clearing the store by hand: the session is + // gone, so every open socket and every cached answer in this tab is now + // about an account nobody is signed in to. + location.href = '/'; + return; + } + + const previous = sessions; + sessions = sessions.filter((s) => s.id !== session.id); + try { + await api.endSession(session.id); + } catch (e) { + sessions = previous; + app.toast(humanMessage(e)); + } + } + + /** + * "for 2 minutter siden". + * + * Relative rather than a timestamp, because the question is "was that me, just + * now?" and an absolute time makes the reader do the subtraction. Anything + * older than a week gets its date instead: "for 23 dage siden" is a number + * nobody can place. + */ + function ago(iso) { + const then = new Date(iso); + const seconds = Math.round((Date.now() - then) / 1000); + + if (seconds < 60) return 'lige nu'; + if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`; + if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`; + if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`; + return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' }); + }
@@ -367,6 +415,35 @@ {/if}
+
+
+

Enheder

+

+ Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift + adgangskode. +

+
+ +
    + {#each sessions as session (session.id)} +
  • +
    + + {session.device} + {#if session.current}denne enhed{/if} + + + + {ago(session.last_seen_at)}{#if session.ip}{' · ' + session.ip}{/if} + +
    + +
  • + {/each} +
+
+