package user import ( "encoding/json" "net/http" "strconv" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" ) // envDevice signs a device in through the assembled routes: initiate, // authorize by session, first poll. It returns the device's session token, // its jti and the session row id. func envDevice(t *testing.T, env *adminEnv, session, name string) (string, string, uint) { t.Helper() rec := env.call(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", map[string]string{"device_name": name}) if rec.Code != http.StatusOK { t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) } var started struct { Data deviceInitiated `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &started); err != nil { t.Fatal(err) } env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/devices/authorize", session, map[string]string{"token": started.Data.Token}) rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices/status/"+started.Data.Token, "", nil) var polled struct { Data struct { Token string `json:"token"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &polled); err != nil || polled.Data.Token == "" { t.Fatalf("hand-over %s: %v", rec.Body.String(), err) } _, _, _, jti, err := bouncer.VerifyClaims(polled.Data.Token, sessionSecret) if err != nil { t.Fatal(err) } var row models.DeviceAuthSession if err := env.gdb.Where("jwt_jti = ?", jti).Take(&row).Error; err != nil { t.Fatal(err) } return polled.Data.Token, jti, row.ID } type deviceListBody struct { Success bool `json:"success"` Devices []struct { ID uint `json:"id"` DeviceName string `json:"device_name"` DeviceIP *string `json:"device_ip"` AuthorizedAt *string `json:"authorized_at"` LastActivity *string `json:"last_activity"` IsCurrent bool `json:"is_current"` } `json:"devices"` } func envDevices(t *testing.T, env *adminEnv, token string) deviceListBody { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/devices", token, nil) var body deviceListBody if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } return body } // TestDeviceRevokeSmoke lists and revokes device sessions through the // assembled routes, including a device that refreshed its token first. func TestDeviceRevokeSmoke(t *testing.T) { if testing.Short() { t.Skip("Postgres") } env := newAdminEnv(t) owner := env.seedUser(t, "dev-owner", nil) other := env.seedUser(t, "dev-other", nil) status, session := env.userLogin(t, owner.Email, "correct-horse") if status != http.StatusOK { t.Fatalf("owner login = %d", status) } otherStatus, otherSession := env.userLogin(t, other.Email, "correct-horse") if otherStatus != http.StatusOK { t.Fatalf("other login = %d", otherStatus) } tvToken, _, tvID := envDevice(t, env, session, "Living room TV") // A second used session with no name and an older activity, a pending // session of the owner and a device of another user. hourAgo := time.Now().Add(-time.Hour) ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)" oldJTI := "dev-old-" + strconv.FormatInt(time.Now().UnixNano(), 10) ownerID := owner.ID phone := models.DeviceAuthSession{TokenHash: "a" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusUsed, ExpiresAt: &hourAgo, DeviceUserAgent: &ua, ConfirmedAt: &hourAgo, LastActivityAt: &hourAgo, JWTJTI: &oldJTI} pendingRow := models.DeviceAuthSession{TokenHash: "b" + strconv.FormatInt(time.Now().UnixNano(), 10), UserID: &ownerID, Status: models.DeviceStatusPending, ExpiresAt: &hourAgo} if err := env.gdb.Create(&phone).Error; err != nil { t.Fatal(err) } if err := env.gdb.Create(&pendingRow).Error; err != nil { t.Fatal(err) } _, _, foreignID := envDevice(t, env, otherSession, "Other TV") t.Run("list", func(t *testing.T) { if rec := env.call(t, http.MethodGet, "/_user/api/v1/devices", "", nil); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` { t.Fatalf("list without a session = %d %s", rec.Code, rec.Body.String()) } list := envDevices(t, env, tvToken) if !list.Success || len(list.Devices) != 2 { t.Fatalf("list = %+v", list) } first, second := list.Devices[0], list.Devices[1] if first.ID != tvID || first.DeviceName != "Living room TV" || !first.IsCurrent || first.AuthorizedAt == nil || first.LastActivity == nil { t.Fatalf("first device = %+v", first) } if second.ID != phone.ID || second.DeviceName != "iPhone" || second.IsCurrent { t.Fatalf("second device = %+v", second) } for _, d := range envDevices(t, env, session).Devices { if d.IsCurrent { t.Fatalf("device %d is current for the owner's own session", d.ID) } } }) t.Run("revoke", func(t *testing.T) { env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil) rec := env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil) if rec.Body.String() != `{"message":"Device revoked successfully","success":true}` { t.Fatalf("revoke body = %s", rec.Body.String()) } env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", tvToken, nil) env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(tvID), 10), session, nil) rec = env.expect(t, http.StatusNotFound, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(foreignID), 10), session, nil) if rec.Body.String() != `{"error":"Device not found"}` { t.Fatalf("foreign revoke body = %s", rec.Body.String()) } // The id constraint keeps a non-numeric id away from the handler, // which would answer 401 without a session. if rec := env.call(t, http.MethodDelete, "/_user/api/v1/devices/abc", "", nil); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { t.Fatalf("DELETE /devices/abc = %d %s", rec.Code, rec.Body.String()) } var row models.DeviceAuthSession if err := env.gdb.Where("id = ?", tvID).Take(&row).Error; err != nil || row.Status != models.DeviceStatusRevoked { t.Fatalf("revoked row = %+v %v", row, err) } for _, d := range envDevices(t, env, session).Devices { if d.ID == tvID { t.Fatal("a revoked device is still listed") } } }) t.Run("revoke_after_refresh", func(t *testing.T) { deskToken, deskJTI, deskID := envDevice(t, env, session, "Desktop") rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", deskToken, nil) var refreshed struct { Token string `json:"token"` } if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); err != nil || refreshed.Token == "" { t.Fatalf("refresh %s: %v", rec.Body.String(), err) } _, _, _, newJTI, err := bouncer.VerifyClaims(refreshed.Token, sessionSecret) if err != nil || newJTI == deskJTI { t.Fatalf("refreshed jti %q err %v", newJTI, err) } var row models.DeviceAuthSession if err := env.gdb.Where("id = ?", deskID).Take(&row).Error; err != nil || row.JWTJTI == nil || *row.JWTJTI != newJTI { t.Fatalf("row after refresh = %+v %v, want jti %s", row, err, newJTI) } env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil) env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(deskID), 10), session, nil) env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", deskToken, nil) env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/fetch", refreshed.Token, nil) env.expect(t, http.StatusUnauthorized, http.MethodPost, "/_user/api/v1/refresh", refreshed.Token, nil) // The owner's own session is untouched. env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil) }) }