Files
sm-user-plugin/device_revoke_smoke_test.go
Jakub Zych 3b22134292 feat: device list and revoke with the jti followed through refresh
GET /devices lists the caller's confirmed and used device sessions (PHP's
fields, device_name from the stored name or the user agent, is_current
for the session the request's token belongs to). DELETE /devices/{id}
marks the session revoked and blacklists the device token's jti for its
whole refresh window in one transaction. POST /refresh hands a used
session's jwt_jti over to the refreshed token (classes.HandOverDeviceJTI),
so a device that refreshed before the revoke is signed out too.
2026-10-26 10:19:25 +01:00

184 lines
7.8 KiB
Go

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