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.
184 lines
7.8 KiB
Go
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)
|
|
})
|
|
}
|