feat(auth): see where you are signed in, and end one of them
`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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b6b838167
commit
4276892590
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user