feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户
This commit is contained in:
40 files changed
+2139
-179
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
Reference in new issue
Block a user