Files
Felis/internal/api/handlers_account_sessions_test.go
T

323 lines
13 KiB
Go

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