PHP's device flow on golem15_user_device_auth_sessions (PHP's columns,
the token stored as its SHA-256, plus jwt_jti): POST /devices/initiate
answers a 64-character token, an XXXX-XXXX short code, the configurable
auth_url (golem15.user.device_auth.auth_url) and the expiry; a signed-in
user authorizes it with POST /devices/authorize (short_code or token,
one guarded pending-to-confirmed UPDATE); the device's first poll of
GET /devices/status/{token} after that receives a session JWT through one
guarded confirmed-to-used UPDATE, so exactly one poll gets it. Used and
revoked sessions never turn into expired; each initiate deletes a bounded
batch of stale sessions.
321 lines
12 KiB
Go
321 lines
12 KiB
Go
package user
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var deviceShortCodeRE = regexp.MustCompile(`^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`)
|
|
|
|
// TestDeviceCodesSmoke pins the device token, the short code, its
|
|
// normalisation and the auth_url template. No database.
|
|
func TestDeviceCodesSmoke(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for range 1000 {
|
|
tok, err := classes.NewDeviceToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tok) != 64 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" {
|
|
t.Fatalf("token %q is not 64 base64url characters", tok)
|
|
}
|
|
if seen[tok] {
|
|
t.Fatalf("token %q repeated", tok)
|
|
}
|
|
seen[tok] = true
|
|
code, err := classes.NewDeviceShortCode()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !deviceShortCodeRE.MatchString(code) {
|
|
t.Fatalf("short code %q", code)
|
|
}
|
|
}
|
|
|
|
for in, want := range map[string]string{
|
|
"abcd-efgh": "ABCD-EFGH",
|
|
"ABCDEFGH": "ABCD-EFGH",
|
|
" ab cd ef gh ": "ABCD-EFGH",
|
|
"2345-6789": "2345-6789",
|
|
} {
|
|
if got, ok := classes.NormalizeDeviceShortCode(in); !ok || got != want {
|
|
t.Fatalf("normalize %q = %q %v, want %q", in, got, ok, want)
|
|
}
|
|
}
|
|
for _, in := range []string{"ABCDEFG", "ABCDEFGHJ", "ABCDEFGO", "ABCDEFGI", "ABCDEFGL", "ABCDEFG0", "ABCDEFG1", ""} {
|
|
if got, ok := classes.NormalizeDeviceShortCode(in); ok {
|
|
t.Fatalf("normalize %q accepted as %q", in, got)
|
|
}
|
|
}
|
|
|
|
app := backpack.New(sessionConfig(t))
|
|
if got := classes.DeviceAuthURL(app, "tok"); got != "/device/scan/tok" {
|
|
t.Fatalf("auth_url without urls = %q", got)
|
|
}
|
|
if err := app.Config.Set("app.url", "https://api.example.test/"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := classes.DeviceAuthURL(app, "tok"); got != "https://api.example.test/device/scan/tok" {
|
|
t.Fatalf("auth_url from app.url = %q", got)
|
|
}
|
|
if err := app.Config.Set("golem15.user.frontend_url", "https://www.example.test/"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/device/scan/tok" {
|
|
t.Fatalf("auth_url from frontend_url = %q", got)
|
|
}
|
|
if err := app.Config.Set("golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/link?device=tok" {
|
|
t.Fatalf("auth_url from a custom template = %q", got)
|
|
}
|
|
if got := classes.DeviceAuthTTL(app); got != 5*time.Minute {
|
|
t.Fatalf("default ttl = %s", got)
|
|
}
|
|
|
|
tv := "Living room TV"
|
|
for _, c := range []struct {
|
|
stored *string
|
|
ua string
|
|
want string
|
|
}{
|
|
{&tv, "Mozilla/5.0 (iPhone)", tv},
|
|
{nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iPhone"},
|
|
{nil, "Mozilla/5.0 (iPad; CPU OS 17_0)", "iPad"},
|
|
{nil, "Mozilla/5.0 (Linux; Android 14)", "Android Device"},
|
|
{nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"},
|
|
{nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"},
|
|
{nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"},
|
|
{nil, "curl/8.0", "Unknown Device"},
|
|
} {
|
|
if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want {
|
|
t.Fatalf("display name of %q = %q, want %q", c.ua, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// deviceCall runs one device handler with an optional bearer token and JSON
|
|
// body; pathToken fills {token} or {id}.
|
|
func deviceCall(t *testing.T, h http.HandlerFunc, method, path, bearerToken, body string, pathValues map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var req *http.Request
|
|
if body != "" {
|
|
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
req.Host = "example.com"
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) SmokeTV")
|
|
if bearerToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
|
}
|
|
for k, v := range pathValues {
|
|
req.SetPathValue(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h(rec, req)
|
|
return rec
|
|
}
|
|
|
|
type deviceInitiated struct {
|
|
Token string `json:"token"`
|
|
ShortCode string `json:"short_code"`
|
|
AuthURL string `json:"auth_url"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
func initiateDevice(t *testing.T, app *backpack.App, name string) deviceInitiated {
|
|
t.Helper()
|
|
body := `{}`
|
|
if name != "" {
|
|
body = `{"device_name":"` + name + `"}`
|
|
}
|
|
rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", body, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out struct {
|
|
Success bool `json:"success"`
|
|
Data deviceInitiated `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || !out.Success {
|
|
t.Fatalf("initiate body %s: %v", rec.Body.String(), err)
|
|
}
|
|
return out.Data
|
|
}
|
|
|
|
func deviceStatus(t *testing.T, app *backpack.App, token string) (int, map[string]any) {
|
|
t.Helper()
|
|
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+token, "", "", map[string]string{"token": token})
|
|
var body map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
return rec.Code, body
|
|
}
|
|
|
|
func authorizeDevice(t *testing.T, app *backpack.App, bearerToken, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return deviceCall(t, controllers.DeviceAuthorize(app), http.MethodPost, "/_user/api/v1/devices/authorize", bearerToken, body, nil)
|
|
}
|
|
|
|
func deviceRow(t *testing.T, gdb *gorm.DB, token string) models.DeviceAuthSession {
|
|
t.Helper()
|
|
var row models.DeviceAuthSession
|
|
if err := gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
func statusData(body map[string]any) map[string]any {
|
|
data, _ := body["data"].(map[string]any)
|
|
return data
|
|
}
|
|
|
|
// TestDeviceFlowSmoke drives initiate, authorize and the one-time hand-over
|
|
// on Postgres.
|
|
func TestDeviceFlowSmoke(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Postgres (testcontainers)")
|
|
}
|
|
app, gdb := sessionApp(t, "0s")
|
|
email := "device-owner-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.test"
|
|
session := loginToken(t, app, gdb, email)
|
|
var owner models.User
|
|
if err := gdb.Where("email = ?", email).Take(&owner).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev := initiateDevice(t, app, "Living room TV")
|
|
if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") {
|
|
t.Fatalf("initiate data = %+v", dev)
|
|
}
|
|
row := deviceRow(t, gdb, dev.Token)
|
|
if row.TokenHash != classes.HashAPIToken(dev.Token) || row.Status != models.DeviceStatusPending || row.UserID != nil ||
|
|
row.DeviceName == nil || *row.DeviceName != "Living room TV" || row.DeviceUserAgent == nil {
|
|
t.Fatalf("stored row = %+v", row)
|
|
}
|
|
if code, body := deviceStatus(t, app, dev.Token); code != http.StatusOK || body["success"] != true || statusData(body)["status"] != "pending" {
|
|
t.Fatalf("pending status = %d %v", code, body)
|
|
}
|
|
if code, body := deviceStatus(t, app, strings.Repeat("z", 64)); code != http.StatusNotFound || body["error"] != "Session not found" {
|
|
t.Fatalf("unknown status = %d %v", code, body)
|
|
}
|
|
|
|
// Authorize needs a session, answering PHP's 401 body.
|
|
if rec := authorizeDevice(t, app, "", `{"short_code":"`+dev.ShortCode+`"}`); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` {
|
|
t.Fatalf("authorize without a session = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := authorizeDevice(t, app, session, `{"short_code":"ABC"}`); rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"Please enter a valid 8-character code"}` {
|
|
t.Fatalf("short code of 3 = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
lower := strings.ToLower(dev.ShortCode)
|
|
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Device authorized successfully","success":true}` {
|
|
t.Fatalf("authorize = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK {
|
|
t.Fatalf("authorizing again for the same user = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// The first poll after confirmation receives the token, once.
|
|
code, body := deviceStatus(t, app, dev.Token)
|
|
data := statusData(body)
|
|
if code != http.StatusOK || data["status"] != "confirmed" || data["message"] != "Device authorized - complete login" || data["user_id"] != float64(owner.ID) {
|
|
t.Fatalf("hand-over = %d %v", code, body)
|
|
}
|
|
jwtToken, _ := data["token"].(string)
|
|
sub, _, _, jti, err := bouncer.VerifyClaims(jwtToken, sessionSecret)
|
|
if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) {
|
|
t.Fatalf("device token sub=%q err=%v", sub, err)
|
|
}
|
|
if user, _ := data["user"].(map[string]any); user["email"] != email {
|
|
t.Fatalf("hand-over user = %v", data["user"])
|
|
}
|
|
row = deviceRow(t, gdb, dev.Token)
|
|
if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil {
|
|
t.Fatalf("row after hand-over = %+v", row)
|
|
}
|
|
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" || statusData(body)["token"] != nil {
|
|
t.Fatalf("second poll = %v", body)
|
|
}
|
|
// The used session is past nothing: even past expires_at it stays used.
|
|
if err := gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", row.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" {
|
|
t.Fatalf("used session past expires_at = %v", body)
|
|
}
|
|
// A used session cannot be authorized again.
|
|
if rec := authorizeDevice(t, app, session, `{"token":"`+dev.Token+`"}`); rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"Invalid or expired code"}` {
|
|
t.Fatalf("authorize a used session = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Ten concurrent polls of a confirmed session yield exactly one token.
|
|
race := initiateDevice(t, app, "")
|
|
if rec := authorizeDevice(t, app, session, `{"token":"`+race.Token+`"}`); rec.Code != http.StatusOK {
|
|
t.Fatalf("authorize by token = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var (
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
tokens int
|
|
used int
|
|
)
|
|
for range 10 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, body := deviceStatus(t, app, race.Token)
|
|
d := statusData(body)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if tok, _ := d["token"].(string); tok != "" {
|
|
tokens++
|
|
} else if d["status"] == "used" {
|
|
used++
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if tokens != 1 || used != 9 {
|
|
t.Fatalf("concurrent polls: %d tokens, %d used", tokens, used)
|
|
}
|
|
|
|
// A pending session past expires_at answers PHP's flat expired body.
|
|
stale := initiateDevice(t, app, "")
|
|
if err := gdb.Model(&models.DeviceAuthSession{}).Where("token = ?", classes.HashAPIToken(stale.Token)).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := authorizeDevice(t, app, session, `{"short_code":"`+stale.ShortCode+`"}`); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("authorize an expired session = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+stale.Token, "", "", map[string]string{"token": stale.Token})
|
|
if rec.Body.String() != `{"message":"Authorization session has expired","status":"expired","success":true}` {
|
|
t.Fatalf("expired status = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if row := deviceRow(t, gdb, stale.Token); row.Status != models.DeviceStatusExpired {
|
|
t.Fatalf("expired row status = %s", row.Status)
|
|
}
|
|
|
|
// device_name is bounded.
|
|
long := strings.Repeat("n", 256)
|
|
if rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", `{"device_name":"`+long+`"}`, nil); rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("device_name of 256 = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|