feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户

This commit is contained in:
Lemon-miaow committed 2026-09-25 14:06:02 +08:00
1 parent 98295e630e
commit 9e7f23ca13
40 files changed
+2139 -179

No files matched your search

+6
View File
@@ -560,6 +560,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
// The caller's own sessions (handlers_account_sessions.go): list every signed-in
// device and sign out one or all the others. App-tier and scoped to the caller
// inside the handler, like the passkey routes above.
{Method: "GET", Pattern: "/api/v1/account/sessions", h: a.handleListMySessions},
{Method: "DELETE", Pattern: "/api/v1/account/sessions/{hash}", h: a.handleRevokeMySession},
{Method: "POST", Pattern: "/api/v1/account/sessions/revoke-others", h: a.handleRevokeMyOtherSessions},
// Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the
// SOURCE drives status → step-up confirm (passkey forced when enrolled, else
// email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not
+91 -16
View File
@@ -75,6 +75,9 @@ type fakeRepo struct {
// failSessionUser / failGetSetting force those reads to fail with a generic
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
failSessionUser error
// failTouchSession / failRevokeOthers force those session writes to fail.
failTouchSession error
failRevokeOthers error
failGetSetting error
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
@@ -210,6 +213,13 @@ type fakeSession struct {
userID string
expiresAt time.Time
revoked bool
// lastSeen is last_seen_at; zero reads as "seen at the moment it is asked
// about", so a literal session in a test is fresh unless it says otherwise.
lastSeen time.Time
createdAt time.Time
userAgent string
clientIP string
touches int
}
// fakeBackup mirrors a world_backups row: the client-facing view plus the
@@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er
}
return nil
}
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
// The API clock minted ExpiresAt, so this is the sign-in time on that clock.
now := ns.ExpiresAt.Add(-sessionTTL)
f.sessions[ns.TokenHash] = &fakeSession{
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now,
userAgent: ns.UserAgent, clientIP: ns.ClientIP,
}
return nil
}
// liveSession mirrors PGRepo's sessionLive: unrevoked, unexpired, its account
// alive, and a staff session seen within staffSessionIdle.
func (f *fakeRepo) liveSession(s *fakeSession, now time.Time) (*StaffUser, bool) {
if s.revoked || !s.expiresAt.After(now) {
return nil, false
}
for _, u := range f.staff {
if u.ID != s.userID {
continue
}
if f.seededDead(u.ID) {
return nil, false
}
if u.Role != "user" && !s.lastSeenAt(now).After(now.Add(-staffSessionIdle)) {
return nil, false
}
return u, true
}
return nil, false
}
func (s *fakeSession) lastSeenAt(now time.Time) time.Time {
if s.lastSeen.IsZero() {
return now
}
return s.lastSeen
}
func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
if f.failSessionUser != nil {
return nil, f.failSessionUser
}
s, ok := f.sessions[tokenHash]
if !ok || s.revoked || !s.expiresAt.After(now) {
if !ok {
return nil, ErrNotFound
}
for _, u := range f.staff {
if u.ID == s.userID {
if f.seededDead(u.ID) {
return nil, ErrNotFound
}
return &SessionedUser{
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
EmailVerified: u.EmailVerified,
}, nil
}
u, ok := f.liveSession(s, now)
if !ok {
return nil, ErrNotFound
}
return nil, ErrNotFound
return &SessionedUser{
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now),
}, nil
}
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
if f.failTouchSession != nil {
return f.failTouchSession
}
if s, ok := f.sessions[tokenHash]; ok && s.lastSeen.Before(now) {
s.lastSeen = now
s.touches++
}
return nil
}
func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
if s, ok := f.sessions[tokenHash]; ok {
@@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
}
return nil
}
func (f *fakeRepo) RevokeUserSession(_ context.Context, userID, tokenHash string) error {
s, ok := f.sessions[tokenHash]
if !ok || s.userID != userID || s.revoked {
return ErrNotFound
}
s.revoked = true
return nil
}
func (f *fakeRepo) RevokeOtherUserSessions(_ context.Context, userID, keepTokenHash string) (int, error) {
if f.failRevokeOthers != nil {
return 0, f.failRevokeOthers
}
n := 0
for hash, s := range f.sessions {
if s.userID == userID && hash != keepTokenHash && !s.revoked {
s.revoked = true
n++
}
}
return n, nil
}
func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
if f.failGetSetting != nil {
return nil, f.failGetSetting
@@ -1331,10 +1402,14 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _
func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) {
var out []SessionView
for hash, s := range f.sessions {
if s.userID == userID && !s.revoked && s.expiresAt.After(now) {
out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt})
if _, live := f.liveSession(s, now); live && s.userID == userID {
out = append(out, SessionView{
TokenHash: hash, CreatedAt: s.createdAt, ExpiresAt: s.expiresAt,
LastSeenAt: s.lastSeenAt(now), UserAgent: s.userAgent, ClientIP: s.clientIP,
})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) })
return out, nil
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"errors"
"log"
"net/http"
"felis.lolicon.best/internal/metrics"
)
// The account holder's own sessions: every device signed in to the account,
// which one is making this request, and a way to sign any of them out. The admin
// routes in handlers_users.go read and revoke the same rows for any user.
// handleListMySessions lists the caller's live sessions, most recently seen
// first, marking the one this request came in on (GET /account/sessions). A
// caller signed in through Cloudflare Access has no session of its own, so none
// is marked.
func (a *API) handleListMySessions(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
sessions, err := a.Repo.ListUserSessions(r.Context(), p.UserID, a.now())
if err != nil {
writeError(w, r, err)
return
}
if sessions == nil {
sessions = []SessionView{}
}
if cur := callerSessionHash(r, p); cur != "" {
for i := range sessions {
sessions[i].Current = sessions[i].TokenHash == cur
}
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
}
// handleRevokeMySession signs out one of the caller's sessions
// (DELETE /account/sessions/{hash}). A hash that is not a live session of the
// caller is 404, whoever it belongs to. Revoking the session this request came
// in on is a sign-out, so the cookie is cleared too.
func (a *API) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
hash := r.PathValue("hash")
if err := a.Repo.RevokeUserSession(r.Context(), p.UserID, hash); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "session_not_found",
"that session has already ended or is not one of yours"))
return
}
writeError(w, r, err)
return
}
current := hash == callerSessionHash(r, p)
if current {
clearSessionCookie(w)
}
metrics.SessionsRevokedTotal.WithLabelValues("self").Inc()
a.audit(r, "account.session.revoked", "")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_out": current})
}
// handleRevokeMyOtherSessions signs out every session of the caller except the
// one this request came in on (POST /account/sessions/revoke-others), and says
// how many it ended.
func (a *API) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p))
if err != nil {
writeError(w, r, err)
return
}
metrics.SessionsRevokedTotal.WithLabelValues("self").Add(float64(n))
a.audit(r, "account.session.revoked_others", "")
writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
}
// revokeOtherSessionsAfter signs out the caller's other devices after a change
// that retires a way in: a removed passkey, or a new verified email replacing the
// address sign-in codes went to. A session opened with the old factor ends with
// it. The change has already committed, so a failure here is logged and the
// request still succeeds; answering an error would invite retrying a change that
// took effect.
func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) {
p := principalFromContext(r.Context())
n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p))
if err != nil {
log.Printf("api: sign out other sessions after %s (request_id=%s): %v", change, requestIDFromContext(r.Context()), err)
return
}
if n > 0 {
metrics.SessionsRevokedTotal.WithLabelValues("security").Add(float64(n))
}
}
// callerSessionHash is the session the request authenticated with, or "" when it
// authenticated some other way (a cookie beside an Access JWT names nothing).
func callerSessionHash(r *http.Request, p *Principal) string {
if p == nil || !p.ViaSession {
return ""
}
return currentSessionHash(r)
}
@@ -0,0 +1,322 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// sessionsFixture signs steve (u1) in on a laptop and a phone and alex (u2) on
// one device, each by a real cookie, so the caller's own session is whichever
// one SessionAuth resolved from the request.
type sessionsFixture struct {
repo *fakeRepo
api *API
eh http.Handler
}
const (
laptopTok = "tok-laptop"
phoneTok = "tok-phone"
alexTok = "tok-alex"
)
func newSessionsFixture(t *testing.T) *sessionsFixture {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["steve"] = &StaffUser{ID: "u1", Username: "steve", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.staff["alex"] = &StaffUser{ID: "u2", Username: "alex", Role: "user"}
api := newTestAPI(repo, newFakeCluster())
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
now := api.now()
for tok, s := range map[string]*fakeSession{
laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5"},
phoneTok: {userID: "u1", lastSeen: now.Add(-2 * time.Hour), userAgent: "Safari on iPhone", clientIP: "198.51.100.7"},
alexTok: {userID: "u2", lastSeen: now.Add(-time.Minute)},
} {
s.expiresAt = now.Add(time.Hour)
repo.sessions[hashCookie(tok)] = s
}
return &sessionsFixture{repo: repo, api: api, eh: api.ExternalHandler()}
}
func asCookie(tok string) map[string]string {
return map[string]string{"Cookie": sessionCookieName + "=" + tok}
}
func (f *sessionsFixture) revoked(tok string) bool {
return f.repo.sessions[hashCookie(tok)].revoked
}
func decodeSessions(t *testing.T, w *httptest.ResponseRecorder) []SessionView {
t.Helper()
var body struct {
Sessions []SessionView `json:"sessions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("sessions body: %v (%s)", err, w.Body.String())
}
return body.Sessions
}
func TestMySessionsListsOwnDevicesAndMarksThisOne(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "GET", "/api/v1/account/sessions", "", asCookie(phoneTok))
if w.Code != http.StatusOK {
t.Fatalf("list = %d (%s)", w.Code, w.Body.String())
}
got := decodeSessions(t, w)
if len(got) != 2 {
t.Fatalf("listed %d sessions, want steve's 2 (alex's is not his): %+v", len(got), got)
}
// Most recently seen first; the phone is the device asking, though it was seen
// less recently than the laptop until this very request.
if got[0].TokenHash != hashCookie(phoneTok) || got[1].TokenHash != hashCookie(laptopTok) {
t.Fatalf("order = %s, %s; want phone (just touched) then laptop", got[0].UserAgent, got[1].UserAgent)
}
if !got[0].Current || got[1].Current {
t.Fatalf("current flags = %v, %v; want only the phone", got[0].Current, got[1].Current)
}
if got[1].UserAgent != "Firefox on Linux" || got[1].ClientIP != "203.0.113.5" {
t.Fatalf("laptop device = %q from %q", got[1].UserAgent, got[1].ClientIP)
}
if strings.Count(w.Body.String(), `"current"`) != 1 {
t.Fatalf("current must be omitted on every other session: %s", w.Body.String())
}
}
// A cookie that rode along beside some other credential is not the session the
// caller signed in with, so nothing is marked current and "sign out the others"
// keeps nothing back.
func TestMySessionsMarkNothingWithoutASessionPrincipal(t *testing.T) {
f := newSessionsFixture(t)
f.api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
eh := f.api.ExternalHandler()
w := do(eh, "GET", "/api/v1/account/sessions", "", asCookie(phoneTok))
for _, s := range decodeSessions(t, w) {
if s.Current {
t.Fatalf("session %s marked current for an Access-signed caller", s.UserAgent)
}
}
if w := do(eh, "POST", "/api/v1/account/sessions/revoke-others", "", asCookie(phoneTok)); w.Code != http.StatusOK {
t.Fatalf("revoke-others = %d (%s)", w.Code, w.Body.String())
}
if !f.revoked(phoneTok) || !f.revoked(laptopTok) {
t.Fatal("an Access-signed caller's revoke-others must end every session")
}
}
func TestRevokeMySessionOnlyReachesOwnSessions(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "DELETE", "/api/v1/account/sessions/"+hashCookie(alexTok), "", asCookie(laptopTok))
if w.Code != http.StatusNotFound || decodeErr(t, w) != "session_not_found" {
t.Fatalf("revoke alex's session as steve = %d (%s), want 404 session_not_found", w.Code, w.Body.String())
}
if f.revoked(alexTok) {
t.Fatal("steve ended alex's session")
}
w = do(f.eh, "DELETE", "/api/v1/account/sessions/"+hashCookie(phoneTok), "", asCookie(laptopTok))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"signed_out":false`) {
t.Fatalf("revoke phone from laptop = %d (%s), want 200 signed_out:false", w.Code, w.Body.String())
}
if !f.revoked(phoneTok) || f.revoked(laptopTok) {
t.Fatal("want the phone ended and the laptop still signed in")
}
if c := w.Header().Get("Set-Cookie"); c != "" {
t.Fatalf("ending another device must leave this one's cookie alone, got Set-Cookie %q", c)
}
if last := f.repo.audits[len(f.repo.audits)-1]; last.Action != "account.session.revoked" || last.ActorUserID != "u1" {
t.Fatalf("audit = %+v", last)
}
}
func TestRevokingThisSessionSignsOut(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "DELETE", "/api/v1/account/sessions/"+hashCookie(laptopTok), "", asCookie(laptopTok))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"signed_out":true`) {
t.Fatalf("revoke own session = %d (%s), want 200 signed_out:true", w.Code, w.Body.String())
}
if c := w.Header().Get("Set-Cookie"); !strings.HasPrefix(c, sessionCookieName+"=;") || !strings.Contains(c, "Max-Age=0") {
t.Fatalf("Set-Cookie = %q, want the session cookie cleared", c)
}
if w := do(f.eh, "GET", "/api/v1/account/sessions", "", asCookie(laptopTok)); w.Code != http.StatusUnauthorized {
t.Fatalf("the ended session still authenticates: %d", w.Code)
}
}
func TestRevokeOtherSessionsKeepsThisOne(t *testing.T) {
f := newSessionsFixture(t)
w := do(f.eh, "POST", "/api/v1/account/sessions/revoke-others", "", asCookie(laptopTok))
if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"revoked":1}` {
t.Fatalf("revoke-others = %d (%s), want 200 {\"revoked\":1}", w.Code, w.Body.String())
}
if f.revoked(laptopTok) || !f.revoked(phoneTok) || f.revoked(alexTok) {
t.Fatalf("after revoke-others laptop=%v phone=%v alex=%v, want only the phone ended",
f.revoked(laptopTok), f.revoked(phoneTok), f.revoked(alexTok))
}
}
// The admin route names the user in its path; a hash of someone else's session
// under it must not end that session.
func TestAdminRevokeSessionChecksWhoseItIs(t *testing.T) {
f := newSessionsFixture(t)
f.api.External = staticExternal{p: &Principal{UserID: "own", Role: "owner", ViaAdminAccess: true}}
eh := f.api.ExternalHandler()
w := do(eh, "DELETE", "/api/v1/users/u1/sessions/"+hashCookie(alexTok), "", nil)
if w.Code != http.StatusNotFound || decodeErr(t, w) != "session_not_found" {
t.Fatalf("alex's hash under steve = %d (%s), want 404 session_not_found", w.Code, w.Body.String())
}
if f.revoked(alexTok) {
t.Fatal("a hash under another user's path ended alex's session")
}
if w := do(eh, "DELETE", "/api/v1/users/u2/sessions/"+hashCookie(alexTok), "", nil); w.Code != http.StatusOK {
t.Fatalf("alex's hash under alex = %d (%s)", w.Code, w.Body.String())
}
if !f.revoked(alexTok) {
t.Fatal("the matching revoke did not end the session")
}
}
func TestRemovingAPasskeySignsOutOtherDevices(t *testing.T) {
f := newSessionsFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", asCookie(laptopTok)); w.Code != http.StatusNoContent {
t.Fatalf("delete passkey = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(laptopTok) || !f.revoked(phoneTok) || f.revoked(alexTok) {
t.Fatalf("after passkey removal laptop=%v phone=%v alex=%v, want only the phone ended",
f.revoked(laptopTok), f.revoked(phoneTok), f.revoked(alexTok))
}
}
func TestVerifyingANewEmailSignsOutOtherDevices(t *testing.T) {
f := newSessionsFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
hdr := asCookie(laptopTok)
hdr["Content-Type"] = "application/json"
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, hdr); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(phoneTok) {
t.Fatal("asking for a code must not sign anything out yet")
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, hdr); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(laptopTok) || !f.revoked(phoneTok) || f.revoked(alexTok) {
t.Fatalf("after email change laptop=%v phone=%v alex=%v, want only the phone ended",
f.revoked(laptopTok), f.revoked(phoneTok), f.revoked(alexTok))
}
}
// With no verified address before, no session was opened through one, so a
// first verification signs nothing out; nor does proving the same address again.
func TestVerifyingAFirstOrSameEmailKeepsOtherDevices(t *testing.T) {
for _, tc := range []struct {
name string
verified bool
address string
}{
{"first address", false, "[email protected]"},
{"same address again", true, "[email protected]"},
} {
t.Run(tc.name, func(t *testing.T) {
f := newSessionsFixture(t)
f.repo.staff["steve"].EmailVerified = tc.verified
mailer := &captureMailer{}
f.api.Mailer = mailer
hdr := asCookie(laptopTok)
hdr["Content-Type"] = "application/json"
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"`+tc.address+`"}`, hdr); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, hdr); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if f.revoked(phoneTok) {
t.Fatal("the phone was signed out")
}
})
}
}
// The change has committed by the time the other sessions are signed out; a
// failure there must not report the change itself as failed.
func TestPasskeyRemovalSucceedsWhenSigningOutOthersFails(t *testing.T) {
f := newSessionsFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
f.repo.failRevokeOthers = errors.New("db blip")
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", asCookie(laptopTok)); w.Code != http.StatusNoContent {
t.Fatalf("delete passkey = %d (%s), want 204 despite the sign-out failure", w.Code, w.Body.String())
}
if _, kept := f.repo.passkeyCreds["a"]; kept {
t.Fatal("the passkey must still be removed")
}
}
func TestSessionActivityIsRecordedAtMostOnceAMinute(t *testing.T) {
f := newSessionsFixture(t)
now := f.api.now()
laptop := f.repo.sessions[hashCookie(laptopTok)]
laptop.lastSeen = now.Add(-30 * time.Second)
do(f.eh, "GET", "/api/v1/me", "", asCookie(laptopTok))
if laptop.touches != 0 {
t.Fatalf("a session seen 30s ago was touched %d times, want 0", laptop.touches)
}
laptop.lastSeen = now.Add(-2 * time.Minute)
do(f.eh, "GET", "/api/v1/me", "", asCookie(laptopTok))
if laptop.touches != 1 || !laptop.lastSeen.Equal(now) {
t.Fatalf("a session seen 2m ago: touches=%d lastSeen=%v, want 1 touch to %v", laptop.touches, laptop.lastSeen, now)
}
// A failed touch leaves the request authenticated.
laptop.lastSeen = now.Add(-2 * time.Minute)
f.repo.failTouchSession = errors.New("db blip")
if w := do(f.eh, "GET", "/api/v1/me", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("/me with a failing touch = %d, want 200", w.Code)
}
}
func TestSignInRecordsTheDevice(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
api.ClientIPHeader = "CF-Connecting-IP"
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start = %d", w.Code)
}
ua := "Mozilla/5.0 x" + strings.Repeat("é", 200) // 413 bytes, é two each
w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"`+mailer.code+`"}`, map[string]string{
"Content-Type": "application/json", "User-Agent": ua, "CF-Connecting-IP": "2001:db8::7",
})
if w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if len(repo.sessions) != 1 {
t.Fatalf("sessions = %d, want 1", len(repo.sessions))
}
for _, s := range repo.sessions {
if s.clientIP != "2001:db8::7" {
t.Errorf("client_ip = %q, want the edge's 2001:db8::7", s.clientIP)
}
// 13 bytes of prefix leave 243 for é: byte 256 falls inside the 122nd, so
// the cut keeps 121 of them, 255 bytes.
if want := "Mozilla/5.0 x" + strings.Repeat("é", 121); s.userAgent != want {
t.Errorf("user_agent = %d bytes %q, want the first 256 bytes on a rune boundary", len(s.userAgent), s.userAgent)
}
}
}
+1 -8
View File
@@ -271,17 +271,10 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
return
}
token, err := newSessionToken()
if err != nil {
if err := a.startSession(w, r, u.ID); err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, u, "auth.login_email", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
+5
View File
@@ -249,6 +249,11 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
return
}
a.audit(r, "account.email.verified", "")
// Replacing a verified address moves where sign-in codes go, so a session
// opened through the old one ends. A first verification retires nothing.
if p.EmailVerified && !strings.EqualFold(p.Email, email) {
a.revokeOtherSessionsAfter(r, "email change")
}
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
}
+1 -8
View File
@@ -125,17 +125,10 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
return
}
token, err := newSessionToken()
if err != nil {
if err := a.startSession(w, r, userID); err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), userID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
// The in-game code proved the Minecraft account; it names the actor.
a.auditEntry(r, AuditEntry{Actor: "mc:" + mcUUID, ActorUserID: userID, Action: "account.bind_redeem"})
writeJSON(w, http.StatusOK, map[string]any{
+1 -8
View File
@@ -326,17 +326,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
return
}
token, err := newSessionToken()
if err != nil {
if err := a.startSession(w, r, u.ID); err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, u, "auth.op_login", "")
writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role})
}
+2 -8
View File
@@ -423,6 +423,7 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
return
}
a.audit(r, "account.passkey.removed", id)
a.revokeOtherSessionsAfter(r, "passkey removal")
w.WriteHeader(http.StatusNoContent)
}
@@ -653,17 +654,10 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
return
}
token, err := newSessionToken()
if err != nil {
if err := a.startSession(w, r, u.ID); err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, u, "auth.passkey_login", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
@@ -197,17 +197,10 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
return
}
token, err := newSessionToken()
if err != nil {
if err := a.startSession(w, r, resolved.ID); err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.auditAccount(r, resolved, "auth.passkey_login_discoverable", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": resolved.ID,
+1 -8
View File
@@ -81,17 +81,10 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
// Mint the session — a regular felis_session; the lockdown is a product-level
// restriction the frontend enforces until email is verified / a passkey is bound.
sessionToken, err := newSessionToken()
if err != nil {
if err := a.startSession(w, r, u.ID); err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(sessionToken), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, sessionToken, expires)
// Report the setup state so the SPA knows which wizard steps remain.
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
+6 -1
View File
@@ -387,7 +387,12 @@ func (a *API) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.Repo.RevokeSession(r.Context(), tokenHash); err != nil {
if err := a.Repo.RevokeUserSession(r.Context(), id, tokenHash); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "session_not_found",
"that session has already ended or does not belong to this user"))
return
}
writeError(w, r, err)
return
}
+72 -20
View File
@@ -1113,27 +1113,35 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string)
// CreateSession records a minted session by the sha-256 of its cookie value
// (spec §B). Only the hash is stored, mirroring tokens.
func (p *PGRepo) CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
tokenHash, userID, expiresAt)
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip)
VALUES ($1, $2, $3, $4, $5)`,
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP)
return err
}
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
// user, or ErrNotFound.
// sessionLive is the condition every reader of live sessions shares, over
// sessions s JOIN users u, with $2 = now and $3 = the staff idle cutoff (now
// minus staffSessionIdle). The disabled/deleted filter is the belt to the doors'
// braces: even a session minted for an account that was alive a moment ago stops
// authenticating the instant the account is disabled or soft-deleted, so every
// authenticated route is fail-closed regardless of which door minted the cookie
// (audit #33). A staff session also dies after sitting idle; a player's lasts
// to its expiry.
const sessionLive = `s.revoked_at IS NULL AND s.expires_at > $2
AND u.disabled = false AND u.deleted_at IS NULL
AND (u.role = 'user' OR s.last_seen_at > $3)`
// SessionUser resolves a live session hash to its user, or ErrNotFound.
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
// The disabled/deleted filter is the belt to the doors' braces: even a session
// minted for an account that was alive a moment ago stops authenticating the
// instant the account is disabled or soft-deleted, so every authenticated route
// is fail-closed regardless of which door minted the cookie (audit #33).
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false)
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false),
s.last_seen_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2
AND u.disabled = false AND u.deleted_at IS NULL`
WHERE s.token_hash = $1 AND ` + sessionLive
var u SessionedUser
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); {
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
@@ -1142,6 +1150,14 @@ func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Tim
return &u, nil
}
// TouchSession advances a session's last_seen_at to now, never backwards.
func (p *PGRepo) TouchSession(ctx context.Context, tokenHash string, now time.Time) error {
_, err := p.db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = $2 WHERE token_hash = $1 AND last_seen_at < $2`,
tokenHash, now)
return err
}
// RevokeSession marks a session revoked (logout). Idempotent: a missing or
// already-revoked session is not an error.
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
@@ -1870,12 +1886,13 @@ func (p *PGRepo) SetQuotas(ctx context.Context, userID string, qi QuotaInput, se
// ---- session admin ----
// ListUserSessions returns every live session for a user, newest first.
// ListUserSessions returns every live session for a user, most recently seen first.
func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error) {
const q = `SELECT token_hash, created_at, expires_at, revoked_at
FROM sessions WHERE user_id = $1 AND (revoked_at IS NULL OR revoked_at > $2) AND expires_at > $2
ORDER BY created_at DESC`
rows, err := p.db.QueryContext(ctx, q, userID, now)
const q = `SELECT s.token_hash, s.created_at, s.expires_at, s.last_seen_at, s.user_agent, s.client_ip
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.user_id = $1 AND ` + sessionLive + `
ORDER BY s.last_seen_at DESC, s.created_at DESC`
rows, err := p.db.QueryContext(ctx, q, userID, now, now.Add(-staffSessionIdle))
if err != nil {
return nil, err
}
@@ -1883,7 +1900,7 @@ func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.T
var out []SessionView
for rows.Next() {
var s SessionView
if err := rows.Scan(&s.TokenHash, &s.CreatedAt, &s.ExpiresAt, &s.RevokedAt); err != nil {
if err := rows.Scan(&s.TokenHash, &s.CreatedAt, &s.ExpiresAt, &s.LastSeenAt, &s.UserAgent, &s.ClientIP); err != nil {
return nil, err
}
out = append(out, s)
@@ -1899,6 +1916,41 @@ func (p *PGRepo) RevokeAllUserSessions(ctx context.Context, userID string) error
return err
}
// RevokeUserSession revokes one unexpired session of userID, or reports
// ErrNotFound when the hash names no such session. The user_id condition is what
// keeps a hash from one account from ending a session of another.
func (p *PGRepo) RevokeUserSession(ctx context.Context, userID, tokenHash string) error {
res, err := p.db.ExecContext(ctx,
`UPDATE sessions SET revoked_at = now()
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()`,
tokenHash, userID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// RevokeOtherUserSessions revokes every unexpired session of userID but
// keepTokenHash, returning how many it ended.
func (p *PGRepo) RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error) {
res, err := p.db.ExecContext(ctx,
`UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > now()`,
userID, keepTokenHash)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
// ---- account-link admin ----
// UnlinkAccount removes a single (user_id, mc_uuid) binding.
+47 -13
View File
@@ -162,6 +162,20 @@ type SessionedUser struct {
Email string
Role string
EmailVerified bool
// LastSeenAt is when the session last authenticated a request, as last
// recorded by TouchSession (so up to sessionTouchEvery stale).
LastSeenAt time.Time
}
// NewSession is one session to record at sign-in: the sha-256 of the opaque
// cookie value, its owner, its absolute expiry, and the device it was minted for,
// so the account's session list can tell the holder which sign-in is which.
type NewSession struct {
TokenHash string
UserID string
ExpiresAt time.Time
UserAgent string
ClientIP string
}
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
@@ -569,16 +583,30 @@ type Repo interface {
// re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is
// promoted. The account is passwordless by design.
UpsertOwner(ctx context.Context, id, username, email string) error
// CreateSession records a minted session: the sha-256 of the opaque cookie
// value, its owner, and its expiry (spec §B sessions). Only the hash is stored,
// mirroring tokens, so a database read never yields a usable cookie.
CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
// user, or ErrNotFound. It is the cookie half of SessionAuth.
// CreateSession records a minted session (spec §B sessions). Only the hash of
// the cookie is stored, mirroring tokens, so a database read never yields a
// usable cookie. The session counts as seen at creation.
CreateSession(ctx context.Context, s NewSession) error
// SessionUser resolves a live session hash to its user, or ErrNotFound. Live
// means unrevoked, unexpired at now, its account neither disabled nor deleted,
// and — for a staff account — seen within staffSessionIdle of now. It is the
// cookie half of SessionAuth.
SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error)
// TouchSession records that the session authenticated a request at now. It
// never moves last_seen_at backwards, and touching an absent session is not
// an error.
TouchSession(ctx context.Context, tokenHash string, now time.Time) error
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
// absent or already-revoked session is not an error.
RevokeSession(ctx context.Context, tokenHash string) error
// RevokeUserSession revokes one live session of userID. A hash that is not a
// live session of that user — another user's, already revoked, expired or
// unknown — is ErrNotFound and changes nothing.
RevokeUserSession(ctx context.Context, userID, tokenHash string) error
// RevokeOtherUserSessions revokes every live session of userID except
// keepTokenHash (every one when keepTokenHash is empty) and reports how many
// it ended.
RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error)
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when the token is absent, already consumed, or
@@ -633,8 +661,8 @@ type Repo interface {
// ---- session admin (admin-only) ----
// ListUserSessions returns every live (unrevoked, unexpired at now) session
// for a user, newest first. An empty list is not an error.
// ListUserSessions returns every live session for a user (live as SessionUser
// defines it), most recently seen first. An empty list is not an error.
ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error)
// RevokeAllUserSessions marks every live session of userID revoked.
// Revoking zero sessions is not an error.
@@ -773,10 +801,16 @@ type ResourceSpec struct {
StorageMB int // storage in megabytes (e.g. 10240 = 10 GiB)
}
// SessionView is one live session row visible to an admin.
// SessionView is one live session row, as the account holder and an admin see
// it. Current is set only on the holder's own list, on the session making the
// request.
type SessionView struct {
TokenHash string `json:"token_hash"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
TokenHash string `json:"token_hash"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastSeenAt time.Time `json:"last_seen_at"`
UserAgent string `json:"user_agent"`
ClientIP string `json:"client_ip"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
Current bool `json:"current,omitempty"`
}
+55 -1
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"strings"
@@ -28,6 +29,17 @@ const (
sessionCookieName = "felis_session"
// sessionTTL bounds a local session. Staff re-authenticate after it.
sessionTTL = 12 * time.Hour
// staffSessionIdle ends a staff session that has authenticated no request for
// this long; a player session has only sessionTTL. Any authenticated request
// counts, a panel tab's background refresh included, so what this ends is a
// session left behind in a closed tab or on a machine that went to sleep.
staffSessionIdle = 30 * time.Minute
// sessionTouchEvery is how stale a session's last_seen_at may grow before a
// request advances it: an active session costs one write a minute, not one per
// request, and the idle limit is honored to within this.
sessionTouchEvery = time.Minute
// maxSessionUserAgent caps the User-Agent a session keeps to name its device.
maxSessionUserAgent = 256
)
// LocalAuthEnabledKey is the platform_settings key that gates whether
@@ -54,6 +66,41 @@ func hashCookie(value string) string {
return hex.EncodeToString(sum[:])
}
// startSession mints a session for userID and sets its cookie. Every sign-in door
// ends here, so every session records the device it was minted for.
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
token, err := newSessionToken()
if err != nil {
return err
}
expires := a.now().Add(sessionTTL)
ip := ""
if addr := a.clientIP(r); addr.IsValid() {
ip = addr.String()
}
if err := a.Repo.CreateSession(r.Context(), NewSession{
TokenHash: hashCookie(token),
UserID: userID,
ExpiresAt: expires,
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
ClientIP: ip,
}); err != nil {
return err
}
setSessionCookie(w, token, expires)
return nil
}
// currentSessionHash is the storage key of the session cookie r carries, or ""
// when it carries none.
func currentSessionHash(r *http.Request) string {
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
return ""
}
return hashCookie(c.Value)
}
// setSessionCookie writes the session cookie: HttpOnly + Secure + SameSite=Lax,
// host-only (no Domain), rooted at "/". Secure means the console must be served
// over HTTPS — already a hard requirement, since WebAuthn and Zero Trust both
@@ -158,13 +205,20 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
return nil, fmt.Errorf("local auth disabled")
}
u, err := s.Repo.SessionUser(ctx, hashCookie(cookie.Value), s.now())
hash, now := hashCookie(cookie.Value), s.now()
u, err := s.Repo.SessionUser(ctx, hash, now)
switch {
case errors.Is(err, ErrNotFound):
return nil, fmt.Errorf("invalid session: %w", err)
case err != nil:
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
}
if now.Sub(u.LastSeenAt) >= sessionTouchEvery {
// A failed touch costs at most an early idle sign-out, so the request goes on.
if err := s.Repo.TouchSession(ctx, hash, now); err != nil {
log.Printf("api: record session activity (request_id=%s): %v", requestIDFromContext(ctx), err)
}
}
return &Principal{
UserID: u.ID,
Username: u.Username,
+4 -1
View File
@@ -105,7 +105,10 @@ var (
}, []string{"door", "reason"})
// SessionsRevokedTotal counts sessions ended before expiry, by who ended
// them: logout (the holder) or admin (the owner revoking a user's sessions).
// them: logout (the holder signing out), self (the holder ending sessions
// from their session list), security (the other sessions ended when the
// holder removes a passkey or changes email) or admin (the owner revoking a
// user's sessions).
SessionsRevokedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "sessions_revoked_total",
+4 -4
View File
@@ -125,7 +125,7 @@ func TestSessionLifecycle(t *testing.T) {
hash := "tok-" + suffix(t)
expires := mustNow().Add(time.Hour)
if err := repo.CreateSession(ctx, hash, u.ID, expires); err != nil {
if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: expires}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
su, err := repo.SessionUser(ctx, hash, mustNow())
@@ -369,7 +369,7 @@ func TestAuditAttributionContract(t *testing.T) {
// The session principal carries the username the rows are signed with.
hash := "audit-sess-" + suffix(t)
if err := repo.CreateSession(ctx, hash, u.ID, mustNow().Add(time.Hour)); err != nil {
if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: mustNow().Add(time.Hour)}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
su, err := repo.SessionUser(ctx, hash, mustNow())
@@ -697,7 +697,7 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
t.Fatalf("alive UserByEmail: %v", err)
}
hash := "h-" + suffix(t)
if err := repo.CreateSession(ctx, hash, u.ID, now.Add(time.Hour)); err != nil {
if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: now.Add(time.Hour)}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if _, err := repo.SessionUser(ctx, hash, now); err != nil {
@@ -750,7 +750,7 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) {
t.Fatalf("deleted UserByEmail = %v, want ErrNotFound", err)
}
hash2 := "h2-" + suffix(t)
if err := repo.CreateSession(ctx, hash2, u.ID, now.Add(time.Hour)); err != nil {
if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash2, UserID: u.ID, ExpiresAt: now.Add(time.Hour)}); err != nil {
t.Fatalf("CreateSession (deleted): %v", err)
}
if _, err := repo.SessionUser(ctx, hash2, now); !errors.Is(err, api.ErrNotFound) {
+189
View File
@@ -0,0 +1,189 @@
//go:build pgint
package pgint
import (
"context"
"errors"
"testing"
"time"
"felis.lolicon.best/internal/api"
)
func newSession(t *testing.T, userID, tag string, expires time.Time) string {
t.Helper()
hash := tag + "-" + suffix(t)
if err := repo.CreateSession(context.Background(), api.NewSession{
TokenHash: hash, UserID: userID, ExpiresAt: expires,
UserAgent: "agent " + tag, ClientIP: "192.0.2.1",
}); err != nil {
t.Fatalf("CreateSession(%s): %v", tag, err)
}
return hash
}
func setLastSeen(t *testing.T, hash string, at time.Time) {
t.Helper()
if _, err := db.Exec(`UPDATE sessions SET last_seen_at = $2 WHERE token_hash = $1`, hash, at); err != nil {
t.Fatal(err)
}
}
// sameMicro compares at timestamptz's microsecond precision.
func sameMicro(a, b time.Time) bool {
d := a.Sub(b)
return d > -time.Microsecond && d < time.Microsecond
}
func listedHashes(t *testing.T, userID string, now time.Time) []string {
t.Helper()
ss, err := repo.ListUserSessions(context.Background(), userID, now)
if err != nil {
t.Fatalf("ListUserSessions: %v", err)
}
var out []string
for _, s := range ss {
out = append(out, s.TokenHash)
}
return out
}
// A staff session dies after 30 idle minutes and a player's does not; both
// SessionUser and the session list agree on which are live.
func TestStaffSessionsIdleOut(t *testing.T) {
ctx := context.Background()
now := mustNow()
admin := newUser(t, "admin", "idle-admin")
player := newUser(t, "user", "idle-player")
fresh := newSession(t, admin.ID, "fresh", now.Add(time.Hour))
stale := newSession(t, admin.ID, "stale", now.Add(time.Hour))
idlePlayer := newSession(t, player.ID, "player", now.Add(time.Hour))
setLastSeen(t, fresh, now.Add(-29*time.Minute))
setLastSeen(t, stale, now.Add(-31*time.Minute))
setLastSeen(t, idlePlayer, now.Add(-5*time.Hour))
su, err := repo.SessionUser(ctx, fresh, now)
if err != nil {
t.Fatalf("staff session idle 29m: %v", err)
}
if want := now.Add(-29 * time.Minute); !sameMicro(su.LastSeenAt, want) {
t.Errorf("LastSeenAt = %v, want %v", su.LastSeenAt, want)
}
if _, err := repo.SessionUser(ctx, stale, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("staff session idle 31m = %v, want ErrNotFound", err)
}
if _, err := repo.SessionUser(ctx, idlePlayer, now); err != nil {
t.Fatalf("player session idle 5h: %v", err)
}
if got := listedHashes(t, admin.ID, now); len(got) != 1 || got[0] != fresh {
t.Fatalf("admin's listed sessions = %v, want only %s", got, fresh)
}
if got := listedHashes(t, player.ID, now); len(got) != 1 || got[0] != idlePlayer {
t.Fatalf("player's listed sessions = %v, want %s", got, idlePlayer)
}
// Activity keeps a staff session alive: a touch at 29m restarts the clock.
if err := repo.TouchSession(ctx, fresh, now); err != nil {
t.Fatalf("TouchSession: %v", err)
}
if _, err := repo.SessionUser(ctx, fresh, now.Add(29*time.Minute)); err != nil {
t.Fatalf("staff session 29m after a touch: %v", err)
}
}
func TestTouchSessionNeverMovesBack(t *testing.T) {
ctx := context.Background()
now := mustNow()
u := newUser(t, "user", "touch")
hash := newSession(t, u.ID, "touch", now.Add(time.Hour))
later := now.Add(10 * time.Minute)
if err := repo.TouchSession(ctx, hash, later); err != nil {
t.Fatal(err)
}
if err := repo.TouchSession(ctx, hash, now); err != nil {
t.Fatal(err)
}
var seen time.Time
if err := db.QueryRow(`SELECT last_seen_at FROM sessions WHERE token_hash = $1`, hash).Scan(&seen); err != nil {
t.Fatal(err)
}
if !sameMicro(seen, later) {
t.Fatalf("last_seen_at = %v after touches at +10m then +0, want %v", seen, later)
}
if err := repo.TouchSession(ctx, "no-such-"+suffix(t), now); err != nil {
t.Fatalf("touching an absent session: %v", err)
}
}
func TestSessionsRecordTheirDevice(t *testing.T) {
now := mustNow()
u := newUser(t, "user", "device")
hash := newSession(t, u.ID, "device", now.Add(time.Hour))
ss, err := repo.ListUserSessions(context.Background(), u.ID, now)
if err != nil || len(ss) != 1 {
t.Fatalf("ListUserSessions = %v, %v", ss, err)
}
s := ss[0]
if s.TokenHash != hash || s.UserAgent != "agent device" || s.ClientIP != "192.0.2.1" {
t.Fatalf("listed session = %+v", s)
}
if s.LastSeenAt.Before(s.CreatedAt) || s.LastSeenAt.IsZero() {
t.Fatalf("a new session counts as seen at creation: created %v, last seen %v", s.CreatedAt, s.LastSeenAt)
}
}
func TestRevokeUserSessionOnlyEndsThatUsersSession(t *testing.T) {
ctx := context.Background()
now := mustNow()
steve := newUser(t, "user", "rv-steve")
alex := newUser(t, "user", "rv-alex")
alexs := newSession(t, alex.ID, "alex", now.Add(time.Hour))
expired := newSession(t, alex.ID, "expired", now.Add(-time.Minute))
if err := repo.RevokeUserSession(ctx, steve.ID, alexs); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoke alex's session as steve = %v, want ErrNotFound", err)
}
if _, err := repo.SessionUser(ctx, alexs, now); err != nil {
t.Fatalf("alex's session after steve's attempt: %v", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, alexs); err != nil {
t.Fatalf("revoke alex's session as alex: %v", err)
}
if _, err := repo.SessionUser(ctx, alexs, now); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoked session still resolves: %v", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, alexs); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoking it again = %v, want ErrNotFound", err)
}
if err := repo.RevokeUserSession(ctx, alex.ID, expired); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("revoking an expired session = %v, want ErrNotFound", err)
}
}
func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) {
ctx := context.Background()
now := mustNow()
steve := newUser(t, "user", "ro-steve")
alex := newUser(t, "user", "ro-alex")
keep := newSession(t, steve.ID, "keep", now.Add(time.Hour))
a := newSession(t, steve.ID, "a", now.Add(time.Hour))
b := newSession(t, steve.ID, "b", now.Add(time.Hour))
newSession(t, steve.ID, "gone", now.Add(-time.Minute))
alexs := newSession(t, alex.ID, "alex", now.Add(time.Hour))
n, err := repo.RevokeOtherUserSessions(ctx, steve.ID, keep)
if err != nil || n != 2 {
t.Fatalf("RevokeOtherUserSessions = %d, %v; want the 2 unexpired others", n, err)
}
if got := listedHashes(t, steve.ID, now); len(got) != 1 || got[0] != keep {
t.Fatalf("steve's live sessions = %v, want only %s (a=%s b=%s ended)", got, keep, a, b)
}
if _, err := repo.SessionUser(ctx, alexs, now); err != nil {
t.Fatalf("alex's session after steve's revoke-others: %v", err)
}
if n, err := repo.RevokeOtherUserSessions(ctx, steve.ID, ""); err != nil || n != 1 {
t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err)
}
}
+1 -1
View File
@@ -175,7 +175,7 @@ func TestSetUserDisabledIsAtomic(t *testing.T) {
ctx := context.Background()
u := newUser(t, "user", "disable")
hash := "h-" + suffix(t)
if err := repo.CreateSession(ctx, hash, u.ID, mustNow().Add(time.Hour)); err != nil {
if err := repo.CreateSession(ctx, api.NewSession{TokenHash: hash, UserID: u.ID, ExpiresAt: mustNow().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,9 @@
-- What a session list shows about each device. last_seen_at is when the session
-- last authenticated a request (the API advances it at most once a minute); a
-- staff session that sits idle past the idle limit stops authenticating.
-- user_agent and client_ip are recorded once, at sign-in. Existing rows count
-- as seen now, so the migration signs nobody out.
ALTER TABLE sessions
ADD COLUMN last_seen_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN user_agent text NOT NULL DEFAULT '',
ADD COLUMN client_ip text NOT NULL DEFAULT '';