diff --git a/API.md b/API.md index 1442ea0..54d3e27 100644 --- a/API.md +++ b/API.md @@ -213,7 +213,7 @@ alert shapes documented here). | --- | --- | --- | --- | | `me` | GET | `GET /auth/me` | — (access token header only) | | `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op | -| `login` | POST | `POST /auth/login` | `{ "username": "…", "password": "…" }` | +| `login` | POST | `POST /auth/login` | `{ "email": "…", "password": "…" }` (the hub identifies accounts by email) | | `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` | | `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` | | `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` | @@ -222,6 +222,36 @@ Direct `/auth/*` routes exist for the same calls; the RN clients call those for login/register (raw body, no envelope). These `/action/` variants are the envelope-wrapped form of the same operations. +### Anonymous subscribers (rev 10) + +Receiving alarms needs no account. There is **no subscriber account, synthetic +or otherwise**: the subscription row is the identity, and the service mints a +device token for it at join time. + +1. `joinAlert` is called with **no `X-Access-Token`** — the shared subscribe + token from the QR is the only proof required. The response carries + `deviceToken`, returned **once**, on the join that created or rotated it. +2. Every later subscriber call sends that token as `X-Device-Token` + (`?deviceToken=` for the WebSocket upgrade, which cannot set headers): + `getAlert`, `alarmStatus`, `ackAlarm`, `removeSubscription`, `qrImage` and + `GET /ws/alarm`. +3. The token is scoped to exactly one alert and one subscription. It can read + that alert, ring/dismiss its own alarm and drop its own row — nothing else. + `createAlert`, `triggerAlert`, `disarmAlert`, `deleteAlert` and + `listSubscriptions` require an access token (`401 ERR_UNAUTHORIZED`); + owning an alert always needs an account. +4. Re-joining from the same device **rotates** the token: the old one stops + working immediately, which is how a lost token is recovered with the + subscribe token alone. A device is one subscription per alert. +5. `ALARM_ANONYMOUS=false` (env, default on) turns the unauthenticated path off: + `joinAlert` then requires an access token and stores the subscription against + that user, exactly like rev 9. + +The token is 32 CSPRNG bytes (base64url), stored as plain SHA-256 (there is +nothing to stretch); the database never holds the token itself. No `auth.users` +row is created, so nothing outside `alarm.subscriptions` changes when a device +subscribes. + --- ## `createAlert` @@ -320,7 +350,11 @@ Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, Subscribes the caller's device. This is what the QR scan calls. -**POST** `/action/joinAlert` +**POST** `/action/joinAlert` — **no access token required** (rev 10). The shared +subscribe token is the authorisation; the response includes a `deviceToken` +(returned once) that authenticates this device on its later subscriber calls. +With `ALARM_ANONYMOUS=false` the call requires `X-Access-Token` and no device +token is issued. ```json { "payload": { @@ -727,6 +761,7 @@ The service path is: ``` /ws/alarm?alertId=&token=&accessToken=[&deviceId=] +/ws/alarm?alertId=&token=&deviceToken= ``` Through an instance (the supported path) that is @@ -735,8 +770,11 @@ Through an instance (the supported path) that is end, so the 20s ping / 60s pong accounting below is measured between client and service, not against the proxy. -The socket is **authenticated, not anonymous**: the access token identifies the -user and the second token proves the relationship to the alert. +The socket is **authenticated, not anonymous**: one of two credentials +identifies the subscriber — an access token (an account) or a device token +(rev 10, a device that joined without an account; sent as `?deviceToken=` because +the upgrade cannot set headers) — and `token` proves the relationship to the +alert in both cases. ### Authentication diff --git a/database/migrations/010_device_tokens.sql b/database/migrations/010_device_tokens.sql new file mode 100644 index 0000000..09f594c --- /dev/null +++ b/database/migrations/010_device_tokens.sql @@ -0,0 +1,75 @@ +-- Copyright (C) 2026 Dcntrlized +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- 010_device_tokens.sql — subscriber authentication without an account. +-- +-- Contract rev 10. A subscriber is not a user: the subscription row IS the +-- identity, and a device token minted by THIS service at join time +-- authenticates it. No auth.users row, no synthetic email, no hub round-trip — +-- an unauthenticated path, not a fake account. +-- +-- What the token proves is narrow and sufficient: "this device is subscribed to +-- this alert". It authorises reading the alert's public shape, the alarm +-- check-in, acking its own debt and removing its own subscription. It can never +-- create, trigger, disarm or delete an alert — those need the admin token, which +-- a subscriber is never given. +-- +-- Additive and idempotent: accounts keep working exactly as before (a logged-in +-- user's join still records subscriber_user_id and needs no device token). +\c dcntrlized + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN + RETURN; + END IF; + + -- A device identity has no account behind it. + ALTER TABLE alarm.subscriptions + ALTER COLUMN subscriber_user_id DROP NOT NULL; + + -- SHA-256 (hex) of the device token. Never the token itself: a database + -- read must not hand out a credential. NULL for account-backed rows. + ALTER TABLE alarm.subscriptions + ADD COLUMN IF NOT EXISTS device_token_hash CHAR(64); + + -- The event log records who acted. An anonymous subscriber may act (joining + -- is logged), and there is no user id to write, so the column must admit + -- NULL. Trigger/disarm stay account actions and always carry an actor. + ALTER TABLE alarm.alarm_events + ALTER COLUMN actor_user_id DROP NOT NULL; + + -- One subscription per device token: the hash is the lookup key on every + -- subscriber request, so a collision would be ambiguous by construction. + CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_device_token_idx + ON alarm.subscriptions (device_token_hash) + WHERE device_token_hash IS NOT NULL; + + -- One device is one subscription per alert, whichever path created it. The + -- pre-rev-10 unique key included the user id, so existing data cannot carry + -- duplicates in practice; the guard keeps a surprise from failing a + -- migration on a live instance (the join path rotates rather than inserts). + IF NOT EXISTS ( + SELECT 1 + FROM alarm.subscriptions + GROUP BY alert_id, device_id + HAVING count(*) > 1 + ) THEN + CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_alert_device_idx + ON alarm.subscriptions (alert_id, device_id); + ELSE + RAISE NOTICE 'alarm.subscriptions has duplicate (alert_id, device_id) rows; skipping the unique index'; + END IF; +END $$; diff --git a/src/actions/ack.go b/src/actions/ack.go index 1429c97..6ea91cf 100644 --- a/src/actions/ack.go +++ b/src/actions/ack.go @@ -54,7 +54,7 @@ type ackAlarmResponse struct { // alert no longer exists, which is the intended outcome, not a failure. func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) { const action = "ackAlarm" - p, ok := principal(w, r, action) + c, ok := callerFor(w, r, action) if !ok { return } @@ -79,34 +79,67 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) { dbError(w, action, err) return } - allowed, err := isAdminOrSubscriber(r, alert, p.ID) - if err != nil { - dbError(w, action, err) - return - } - if !allowed { - writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") - return + if c.IsDevice { + if !c.subscribedTo(alertID) { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + } else { + allowed, err := isAdminOrSubscriber(r, alert, c.UserID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } } resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()} - // The owner has no delivery row (only subscribers are owed alarms), so an - // owner ack is a well-formed no-op rather than a validation error. - if alert.AdminUserID != p.ID { - raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId")) - if err != nil { - validationError(w, "%v", err) - return - } - cleared, err := db.AckAlarmDelivery(r.Context(), alertID, p.ID, deviceid.Hash(raw), eventID) + // A device token lives on the subscription, so the ledger row is addressed + // directly: no user id, no device-id echo, nothing to spoof. + if c.IsDevice { + cleared, err := db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID) if err != nil { dbError(w, action, err) return } resp.Acked = cleared if cleared > 0 { - if err := db.RecordDelivered(r.Context(), alertID, p.ID); err != nil { + if err := db.TouchSubscription(r.Context(), c.Sub.ID); err != nil { + dbError(w, action, err) + return + } + destroyed, err := db.CompleteAlertDeliveries(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + resp.Destroyed = destroyed + } + Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t (device)", alertID, eventID, resp.Acked, resp.Destroyed) + writeJSON(w, http.StatusOK, resp) + return + } + + // The owner has no delivery row (only subscribers are owed alarms), so an + // owner ack is a well-formed no-op rather than a validation error. + if alert.AdminUserID != c.UserID { + raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId")) + if err != nil { + validationError(w, "%v", err) + return + } + cleared, err := db.AckAlarmDelivery(r.Context(), alertID, c.UserID, deviceid.Hash(raw), eventID) + if err != nil { + dbError(w, action, err) + return + } + resp.Acked = cleared + if cleared > 0 { + if err := db.RecordDelivered(r.Context(), alertID, c.UserID); err != nil { dbError(w, action, err) return } diff --git a/src/actions/alerts.go b/src/actions/alerts.go index 15cbe6a..969c78d 100644 --- a/src/actions/alerts.go +++ b/src/actions/alerts.go @@ -281,7 +281,7 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) { // admin and by any subscribed user. func GetAlert(w http.ResponseWriter, r *http.Request, payload any) { const action = "getAlert" - p, ok := principal(w, r, action) + c, ok := callerFor(w, r, action) if !ok { return } @@ -296,16 +296,25 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) { dbError(w, action, err) return } - isAdmin := res.Alert.AdminUserID == p.ID + isAdmin := !c.IsDevice && res.Alert.AdminUserID == c.UserID if !isAdmin { - allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID) - if err != nil { - dbError(w, action, err) - return - } - if !allowed { - writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") - return + if c.IsDevice { + // The device token names exactly one subscription, so "subscribed" + // is a field comparison rather than a query. + if !c.subscribedTo(alertID) { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + } else { + allowed, err := db.IsSubscriber(r.Context(), alertID, c.UserID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } } } count, err := db.CountSubscriptions(r.Context(), alertID) diff --git a/src/actions/anonymous_test.go b/src/actions/anonymous_test.go new file mode 100644 index 0000000..1991130 --- /dev/null +++ b/src/actions/anonymous_test.go @@ -0,0 +1,151 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "github.com/google/uuid" +) + +// rev 10: an anonymous subscriber is a device credential, never a fake user. + +func TestMintDeviceTokenIsRandomAndURLSafe(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 64; i++ { + tok, err := mintDeviceToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + if len(tok) != 43 { + t.Fatalf("token length = %d, want 43 (32 bytes base64url)", len(tok)) + } + if strings.ContainsAny(tok, "+/=") { + t.Fatalf("token %q is not base64url", tok) + } + if seen[tok] { + t.Fatalf("mint repeated a token") + } + seen[tok] = true + } +} + +func TestHashDeviceTokenIsStableSHA256Hex(t *testing.T) { + const tok = "device-token-under-test" + first := hashDeviceToken(tok) + if len(first) != 64 { + t.Fatalf("hash length = %d, want 64 hex", len(first)) + } + if first != hashDeviceToken(tok) { + t.Fatalf("hash is not deterministic") + } + if first == hashDeviceToken(tok+"x") { + t.Fatalf("different tokens hashed the same") + } +} + +func TestAnonymousEnabledSwitch(t *testing.T) { + cases := map[string]bool{ + "": true, + "true": true, + "1": true, + "yes": true, + "on": true, + "false": false, + "0": false, + "no": false, + "off": false, + " OFF ": false, + "maybe": true, + } + for value, want := range cases { + t.Setenv("ALARM_ANONYMOUS", value) + if got := anonymousEnabled(); got != want { + t.Errorf("anonymousEnabled(%q) = %v, want %v", value, got, want) + } + } +} + +func TestDeviceTokenReadsHeaderThenQuery(t *testing.T) { + r := httptest.NewRequest("GET", "/action/getAlert?deviceToken=from-query", nil) + if got := deviceToken(r); got != "from-query" { + t.Fatalf("query token = %q", got) + } + r = httptest.NewRequest("GET", "/action/getAlert?deviceToken=from-query", nil) + r.Header.Set(deviceTokenHeader, "from-header") + if got := deviceToken(r); got != "from-header" { + t.Fatalf("header token = %q", got) + } + r = httptest.NewRequest("GET", "/action/getAlert", nil) + if got := deviceToken(r); got != "" { + t.Fatalf("absent token = %q", got) + } +} + +func TestCallerSubscribedToIsScopedToItsAlert(t *testing.T) { + alertID := uuid.New() + other := uuid.New() + + device := &caller{IsDevice: true, Sub: &db.Subscription{AlertID: alertID}} + if !device.subscribedTo(alertID) { + t.Fatalf("device should be subscribed to its own alert") + } + if device.subscribedTo(other) { + t.Fatalf("a device token must not reach another alert") + } + + // An account caller has no subscription object; the account check is done by + // the caller's own isAdminOrSubscriber path. + account := &caller{UserID: uuid.New()} + if account.subscribedTo(alertID) { + t.Fatalf("an account caller is not a device subscription") + } +} + +func TestJoinResponseCarriesTokenButNoUserIdForANonAccount(t *testing.T) { + alertID, subID := uuid.New(), uuid.New() + raw, err := json.Marshal(joinResponse{ + subscriptionView: newSubscriptionView(&db.Subscription{ + ID: subID, AlertID: alertID, DeviceID: "hashed", + }), + DeviceToken: "tok", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded["deviceToken"] != "tok" { + t.Fatalf("deviceToken missing from join response: %s", raw) + } + if _, present := decoded["userId"]; present { + t.Fatalf("an anonymous subscription must not report a userId: %s", raw) + } + + // An account-backed subscription still reports it. + withUser := newSubscriptionView(&db.Subscription{ + ID: subID, AlertID: alertID, SubscriberUserID: uuid.New(), DeviceID: "hashed", + }) + if withUser.UserID == "" { + t.Fatalf("an account subscription must report its userId") + } +} diff --git a/src/actions/deviceauth.go b/src/actions/deviceauth.go new file mode 100644 index 0000000..538b1b9 --- /dev/null +++ b/src/actions/deviceauth.go @@ -0,0 +1,102 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "strings" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "github.com/google/uuid" +) + +// deviceTokenHeader carries the subscriber credential on every request that +// does not come from an account. The query fallback exists for the WebSocket +// upgrade, which cannot set headers. +const deviceTokenHeader = "X-Device-Token" + +// caller is the resolved identity of a subscriber-side request. Exactly one of +// the two shapes is populated: +// +// account — UserID set, Sub nil (a logged-in user, rev 9 behaviour) +// device — Sub set, IsDevice true (an anonymous subscriber, rev 10) +type caller struct { + UserID uuid.UUID + Sub *db.Subscription + IsDevice bool +} + +// deviceToken reads the device credential from the header, then the query. +func deviceToken(r *http.Request) string { + if tok := strings.TrimSpace(r.Header.Get(deviceTokenHeader)); tok != "" { + return tok + } + return strings.TrimSpace(r.URL.Query().Get("deviceToken")) +} + +// callerFor resolves the two credential kinds a subscriber-side action accepts. +// A device token is checked first: it is the cheaper path (one indexed lookup, +// no hub round-trip) and it must win when both are present, because a device +// credential is narrower than an account. +func callerFor(w http.ResponseWriter, r *http.Request, action string) (*caller, bool) { + if tok := deviceToken(r); tok != "" { + sub, err := db.GetSubscriptionByToken(r.Context(), hashDeviceToken(tok)) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + Warn(action, "auth", "unknown device token") + writeError(w, http.StatusUnauthorized, CodeUnauthorized, "invalid device token") + return nil, false + } + dbError(w, action, err) + return nil, false + } + return &caller{Sub: sub, IsDevice: true}, true + } + p, ok := principal(w, r, action) + if !ok { + return nil, false + } + return &caller{UserID: p.ID}, true +} + +// subscribedTo reports whether the caller may read the alert's subscriber +// surface. A device token is scoped to exactly one alert; an account is checked +// against the alert's admin and subscription rows by the caller. +func (c *caller) subscribedTo(alertID uuid.UUID) bool { + return c.IsDevice && c.Sub != nil && c.Sub.AlertID == alertID +} + +// mintDeviceToken produces the 256-bit credential a device receives once, at +// join. base64url keeps it header-safe. +func mintDeviceToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// hashDeviceToken is the only transform applied to a device token: plain +// SHA-256 of 32 random bytes. The database never sees the token itself. +func hashDeviceToken(tok string) string { + sum := sha256.Sum256([]byte(tok)) + return hex.EncodeToString(sum[:]) +} diff --git a/src/actions/errors.go b/src/actions/errors.go index 93edcd9..63d9c65 100644 --- a/src/actions/errors.go +++ b/src/actions/errors.go @@ -27,6 +27,7 @@ import ( "errors" "fmt" "net/http" + "os" "strings" "time" @@ -108,6 +109,17 @@ func principal(w http.ResponseWriter, r *http.Request, action string) (*authclie return p, true } +// anonymousEnabled reports whether this instance accepts device-token joins. +// Default on; ALARM_ANONYMOUS=false/0/no/off makes joinAlert require an account +// again (subscriber-only access needs a login, exactly like rev 9). +func anonymousEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("ALARM_ANONYMOUS"))) { + case "false", "0", "no", "off": + return false + } + return true +} + // decodePayload unmarshals the {payload} wrapper's inner value into dst. // // Unknown fields are deliberately ignored: the clients are shipped separately diff --git a/src/actions/events.go b/src/actions/events.go index a45cfe6..8f108e4 100644 --- a/src/actions/events.go +++ b/src/actions/events.go @@ -187,7 +187,7 @@ type statusView struct { // background job is needed to expire anything. func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { const action = "alarmStatus" - p, ok := principal(w, r, action) + c, ok := callerFor(w, r, action) if !ok { return } @@ -202,14 +202,21 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { dbError(w, action, err) return } - allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID) - if err != nil { - dbError(w, action, err) - return - } - if !allowed { - writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") - return + if c.IsDevice { + if !c.subscribedTo(alertID) { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + } else { + allowed, err := isAdminOrSubscriber(r, res.Alert, c.UserID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } } // Offline check-in, READ-ONLY. If this device still owes an alarm, answer @@ -218,14 +225,29 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { // 409 lost in transit must not forfeit the alarm: the next check-in has to // find the same row. A trigger that has since been disarmed is not news, so // that case falls through to the normal body (active:false). - deviceHash := "" - if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" { - deviceHash = deviceid.Hash(raw) - } - pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash) - if err != nil && !errors.Is(err, db.ErrNotFound) { - dbError(w, action, err) - return + var pending *db.PendingDelivery + if c.IsDevice { + // The device token already identifies the subscription, so its whole + // pending set is the debt to report. + rows, err := db.PendingReplay(r.Context(), alertID, c.Sub.ID) + if err != nil && !errors.Is(err, db.ErrNotFound) { + dbError(w, action, err) + return + } + if len(rows) > 0 { + pending = &rows[0] + } + } else { + deviceHash := "" + if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" { + deviceHash = deviceid.Hash(raw) + } + var err error + pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash) + if err != nil && !errors.Is(err, db.ErrNotFound) { + dbError(w, action, err) + return + } } if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger { writeOfflineAlarm(w, res.Alert, pending) diff --git a/src/actions/qr.go b/src/actions/qr.go index af2c3d9..35ba4d5 100644 --- a/src/actions/qr.go +++ b/src/actions/qr.go @@ -40,7 +40,7 @@ const ( // through createAlert/qrPayload, the subscriber through the token they scanned). func QRImage(w http.ResponseWriter, r *http.Request, payload any) { const action = "qrImage" - p, ok := principal(w, r, action) + c, ok := callerFor(w, r, action) if !ok { return } @@ -55,14 +55,21 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) { dbError(w, action, err) return } - allowed, err := isAdminOrSubscriber(r, alert, p.ID) - if err != nil { - dbError(w, action, err) - return - } - if !allowed { - writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") - return + if c.IsDevice { + if !c.subscribedTo(alertID) { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + } else { + allowed, err := isAdminOrSubscriber(r, alert, c.UserID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } } scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale) diff --git a/src/actions/subscriptions.go b/src/actions/subscriptions.go index 9698817..9223c48 100644 --- a/src/actions/subscriptions.go +++ b/src/actions/subscriptions.go @@ -28,7 +28,7 @@ import ( type subscriptionView struct { ID string `json:"id"` - UserID string `json:"userId"` + UserID string `json:"userId,omitempty"` Username string `json:"username,omitempty"` DeviceLabel string `json:"deviceLabel,omitempty"` Platform string `json:"platform,omitempty"` @@ -52,19 +52,33 @@ type subscriptionView struct { // schema, and the Alarm service does not read another service's tables. The // field is omitted rather than sent empty. func newSubscriptionView(s *db.Subscription) subscriptionView { - return subscriptionView{ + view := subscriptionView{ ID: s.ID.String(), - UserID: s.SubscriberUserID.String(), DeviceLabel: deref(s.DeviceLabel), Platform: deref(s.Platform), Enabled: s.Enabled, LastSeenAt: rfc3339Ptr(s.LastSeenAt), Created: rfc3339(s.Created), } + // An anonymous subscriber has no account behind it, so there is no user id + // to report (the zero uuid would be a lie, not an empty value). + if s.SubscriberUserID != uuid.Nil { + view.UserID = s.SubscriberUserID.String() + } + return view } // ---- joinAlert -------------------------------------------------------------- +// joinResponse is the subscription plus the device credential. The token is +// returned ONCE, on the call that created (or rotated) it; there is no way to +// fetch it again, which is why losing the app's storage means losing the +// subscriber identity. +type joinResponse struct { + subscriptionView + DeviceToken string `json:"deviceToken,omitempty"` +} + type joinRequest struct { AlertID string `json:"alertId"` SubscribeToken string `json:"subscribeToken"` @@ -82,10 +96,6 @@ type joinRequest struct { // race, where two inserts for the same device hit the unique index at once. func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) { const action = "joinAlert" - p, ok := principal(w, r, action) - if !ok { - return - } var req joinRequest if err := decodePayload(payload, &req); err != nil { @@ -126,34 +136,62 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) { dbError(w, action, err) return } + // The subscribe token is the only thing that authorises a join: it is the + // proof that the caller was shown the QR. No account, no device identity — + // this is the unauthenticated path, not a fake user. if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) { - Warn(action, "auth", "subscribe token mismatch alert=%s user=%s", alert.ID, p.ID) + Warn(action, "auth", "subscribe token mismatch alert=%s", alert.ID) writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token") return } - existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, storedDeviceID) + // An operator can switch anonymous access off; then a join is an account + // action again and the subscription is owned by that user. + if !anonymousEnabled() { + joinAsAccount(w, r, action, alert, storedDeviceID, label, platform) + return + } + + // Re-join from a known device: rotate the credential instead of creating a + // second row. The old token stops working immediately, which is what makes + // a lost/leaked device token recoverable with the subscribe token alone. + existing, err := db.FindSubscriptionByDevice(r.Context(), alertID, storedDeviceID) switch { case err == nil: - Debug(action, "POST", "idempotent re-join alert=%s sub=%s", alertID, existing.ID) - writeJSON(w, http.StatusOK, newSubscriptionView(existing)) + token, terr := mintDeviceToken() + if terr != nil { + dbError(w, action, terr) + return + } + if err := db.RotateDeviceToken(r.Context(), existing.ID, hashDeviceToken(token)); err != nil { + dbError(w, action, err) + return + } + Debug(action, "POST", "re-join alert=%s sub=%s (token rotated)", alertID, existing.ID) + writeJSON(w, http.StatusOK, joinResponse{newSubscriptionView(existing), token}) return case !errors.Is(err, db.ErrNotFound): dbError(w, action, err) return } + token, err := mintDeviceToken() + if err != nil { + dbError(w, action, err) + return + } + tokenHash := hashDeviceToken(token) sub, err := db.CreateSubscription(r.Context(), &db.Subscription{ - AlertID: alertID, - SubscriberUserID: p.ID, - DeviceID: storedDeviceID, - DeviceLabel: label, - Platform: platform, + AlertID: alertID, + DeviceID: storedDeviceID, + DeviceLabel: label, + Platform: platform, + DeviceTokenHash: &tokenHash, }) if errors.Is(err, db.ErrConflict) { // Two concurrent joins for the same device: the sequential repeat path // above already handled the normal re-scan case. - Warn(action, "POST", "concurrent duplicate join alert=%s user=%s", alertID, p.ID) + Warn(action, "POST", "concurrent duplicate join alert=%s", alertID) writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert") return } @@ -162,7 +200,42 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) { return } - Debug(action, "POST", "joined alert=%s user=%s sub=%s", alertID, p.ID, sub.ID) + Debug(action, "POST", "joined alert=%s sub=%s (anonymous)", alertID, sub.ID) + writeJSON(w, http.StatusOK, joinResponse{newSubscriptionView(sub), token}) +} + +// joinAsAccount is the ALARM_ANONYMOUS=false path: the same join, but the +// caller must hold an access token and the row carries their user id. +func joinAsAccount(w http.ResponseWriter, r *http.Request, action string, alert *db.Alert, + deviceID string, label, platform *string) { + p, ok := principal(w, r, action) + if !ok { + return + } + existing, err := db.GetSubscriptionByDevice(r.Context(), alert.ID, p.ID, deviceID) + switch { + case err == nil: + writeJSON(w, http.StatusOK, newSubscriptionView(existing)) + return + case !errors.Is(err, db.ErrNotFound): + dbError(w, action, err) + return + } + sub, err := db.CreateSubscription(r.Context(), &db.Subscription{ + AlertID: alert.ID, + SubscriberUserID: p.ID, + DeviceID: deviceID, + DeviceLabel: label, + Platform: platform, + }) + if errors.Is(err, db.ErrConflict) { + writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert") + return + } + if err != nil { + dbError(w, action, err) + return + } writeJSON(w, http.StatusOK, newSubscriptionView(sub)) } @@ -203,7 +276,7 @@ type removeSubscriptionRequest struct { // alert's admin may remove any device; a subscriber may remove their own. func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) { const action = "removeSubscription" - p, ok := principal(w, r, action) + c, ok := callerFor(w, r, action) if !ok { return } @@ -237,17 +310,22 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) { return } - if sub.SubscriberUserID != p.ID { - // Not the owner: only the alert admin may remove someone else's device. - // A stranger gets the same code as a missing row so the endpoint never - // confirms that the subscription exists. + // A device token is scoped to its own subscription: it can remove that row + // and nothing else. An account may remove its own device, or any device on + // an alert it administers. + if c.IsDevice { + if c.Sub.ID != sub.ID { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found") + return + } + } else if sub.SubscriberUserID != c.UserID { alert, err := db.GetAlert(r.Context(), alertID) if err != nil { dbError(w, action, err) return } - if alert.AdminUserID != p.ID { - Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID) + if alert.AdminUserID != c.UserID { + Warn(action, "auth", "user=%s may not remove sub=%s", c.UserID, subscriptionID) writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found") return } @@ -257,7 +335,7 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) { dbError(w, action, err) return } - Debug(action, "POST", "removed sub=%s alert=%s by=%s", subscriptionID, alertID, p.ID) + Debug(action, "POST", "removed sub=%s alert=%s", subscriptionID, alertID) writeJSON(w, http.StatusOK, map[string]any{ "alertId": alertID.String(), "subscriptionId": subscriptionID.String(), diff --git a/src/db/events.go b/src/db/events.go index 1037638..ec4425a 100644 --- a/src/db/events.go +++ b/src/db/events.go @@ -27,9 +27,11 @@ import ( // Event is one alarm.alarm_events row (append-only trigger/disarm log). type Event struct { - ID uuid.UUID - AlertID uuid.UUID - Kind string + ID uuid.UUID + AlertID uuid.UUID + Kind string + // ActorUserID is uuid.Nil when the actor is an anonymous subscriber (a + // silent act such as a device joining); the column is nullable for that. ActorUserID uuid.UUID Message *string CreatedAt time.Time @@ -39,7 +41,11 @@ const eventColumns = `id, alert_id, kind::text, actor_user_id, message, created_ func scanEvent(row pgx.Row) (*Event, error) { var e Event - err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &e.ActorUserID, &e.Message, &e.CreatedAt) + var actor *uuid.UUID + err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &actor, &e.Message, &e.CreatedAt) + if actor != nil { + e.ActorUserID = *actor + } if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } diff --git a/src/db/subscriptions.go b/src/db/subscriptions.go index c09708a..be245c5 100644 --- a/src/db/subscriptions.go +++ b/src/db/subscriptions.go @@ -25,6 +25,12 @@ import ( ) // Subscription is one alarm.subscriptions row: a device that receives the alarm. +// +// A subscription is the SUBSCRIBER IDENTITY (contract rev 10). Either it hangs +// off an account (`SubscriberUserID` set, the pre-rev-10 shape) or it stands +// alone with a device token the service minted at join (`DeviceTokenHash` set, +// `SubscriberUserID` is uuid.Nil). A subscriber is therefore never a fake user: +// there is no auth.users row behind an anonymous one. type Subscription struct { ID uuid.UUID AlertID uuid.UUID @@ -35,15 +41,22 @@ type Subscription struct { Enabled bool LastSeenAt *time.Time Created time.Time + // DeviceTokenHash is the SHA-256 of the token handed to the device once, at + // join. Nil for account-backed subscriptions. + DeviceTokenHash *string } const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label, - platform, enabled, last_seen_at, created` + platform, enabled, last_seen_at, created, device_token_hash` func scanSubscription(row pgx.Row) (*Subscription, error) { var s Subscription - err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel, - &s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created) + var user *uuid.UUID + err := row.Scan(&s.ID, &s.AlertID, &user, &s.DeviceID, &s.DeviceLabel, + &s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created, &s.DeviceTokenHash) + if user != nil { + s.SubscriberUserID = *user + } if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -65,19 +78,24 @@ func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, er } defer func() { _ = tx.Rollback(ctx) }() + var actor *uuid.UUID + if s.SubscriberUserID != uuid.Nil { + actor = &s.SubscriberUserID + } + const q = `INSERT INTO alarm.subscriptions - (alert_id, subscriber_user_id, device_id, device_label, platform) - VALUES ($1, $2, $3, $4, $5) + (alert_id, subscriber_user_id, device_id, device_label, platform, device_token_hash) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING ` + subscriptionColumns row, err := scanSubscription(tx.QueryRow(ctx, q, - s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform)) + s.AlertID, actor, s.DeviceID, s.DeviceLabel, s.Platform, s.DeviceTokenHash)) if isUniqueViolation(err) { return nil, ErrConflict } if err != nil { return nil, err } - if err := recordEvent(ctx, tx, s.AlertID, &s.SubscriberUserID, EventJoined, ""); err != nil { + if err := recordEvent(ctx, tx, s.AlertID, actor, EventJoined, ""); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { @@ -94,6 +112,44 @@ func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, dev return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID)) } +// FindSubscriptionByDevice looks a subscription up by alert and device digest, +// whichever path created it. It is the idempotency lookup for an anonymous +// join, where there is no user id to key on. +func FindSubscriptionByDevice(ctx context.Context, alertID uuid.UUID, deviceID string) (*Subscription, error) { + const q = `SELECT ` + subscriptionColumns + ` + FROM alarm.subscriptions + WHERE alert_id = $1 AND device_id = $2 + ORDER BY created + LIMIT 1` + return scanSubscription(Pool().QueryRow(ctx, q, alertID, deviceID)) +} + +// GetSubscriptionByToken resolves the subscriber behind a device token. The +// token itself is never stored: the caller hashes what it received and this +// lookup runs against the unique index. +func GetSubscriptionByToken(ctx context.Context, tokenHash string) (*Subscription, error) { + const q = `SELECT ` + subscriptionColumns + ` + FROM alarm.subscriptions + WHERE device_token_hash = $1` + return scanSubscription(Pool().QueryRow(ctx, q, tokenHash)) +} + +// RotateDeviceToken replaces the token hash of a subscription. A re-join from +// the same device rotates rather than inserts, so a lost token is recoverable +// with the subscribe token while the old one stops working immediately. +func RotateDeviceToken(ctx context.Context, subscriptionID uuid.UUID, tokenHash string) error { + tag, err := Pool().Exec(ctx, + `UPDATE alarm.subscriptions SET device_token_hash = $2 WHERE id = $1`, + subscriptionID, tokenHash) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // GetSubscription fetches one subscription scoped to its alert. func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) { const q = `SELECT ` + subscriptionColumns + ` diff --git a/src/realtime/auth.go b/src/realtime/auth.go index 0ae3600..7749140 100644 --- a/src/realtime/auth.go +++ b/src/realtime/auth.go @@ -16,6 +16,8 @@ package realtime import ( + "crypto/sha256" + "encoding/hex" "errors" "net/http" "strings" @@ -34,6 +36,8 @@ import ( // ?alertId= required // &token= required; the alert's admin token OR its subscribe token // &deviceId= optional; picks which device row is kept alive +// &deviceToken= the subscriber credential (rev 10); replaces the access +// token for a device that has no account // // The caller's identity always comes from a verified X-Access-Token header. The // access token may also be supplied as ?accessToken= / ?access_token= for @@ -44,6 +48,16 @@ import ( // the alert, so knowing the shared token is not enough on its own. The admin // token is only honoured for the user the alert was created by. func authenticateWS(r *http.Request) (*Identity, error) { + // Device path first (rev 10): no access token, no account — the device token + // names a subscription and `token` must still prove the caller was shown the + // QR. + if deviceTok := strings.TrimSpace(r.URL.Query().Get("deviceToken")); deviceTok != "" || strings.TrimSpace(r.Header.Get("X-Device-Token")) != "" { + if deviceTok == "" { + deviceTok = strings.TrimSpace(r.Header.Get("X-Device-Token")) + } + return authenticateWSDevice(r, deviceTok) + } + access := strings.TrimSpace(r.Header.Get("X-Access-Token")) if access == "" { access = strings.TrimSpace(r.URL.Query().Get("accessToken")) @@ -148,3 +162,70 @@ func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscr } return db.GetSubscription(r.Context(), alertID, ids[0]) } + +// authenticateWSDevice upgrades a subscriber connection that has no account. +// The device token is looked up by hash; the shared subscribe token still has to +// match, so a leaked device token alone cannot attach to an alert's socket. +func authenticateWSDevice(r *http.Request, deviceToken string) (*Identity, error) { + alertID, err := uuid.Parse(strings.TrimSpace(r.URL.Query().Get("alertId"))) + if err != nil { + return nil, &AuthError{ + Status: http.StatusBadRequest, Code: apierr.Validation, Message: "alertId is required", + } + } + sub, err := db.GetSubscriptionByToken(r.Context(), deviceTokenHash(deviceToken)) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, &AuthError{ + Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid device token", + } + } + logger.Error("ws: device token lookup failed: %v", err) + return nil, &AuthError{ + Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error", + } + } + if sub.AlertID != alertID { + return nil, &AuthError{ + Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "device is not subscribed to this alert", + } + } + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + return nil, &AuthError{ + Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "token is required", + } + } + alert, err := db.GetAlert(r.Context(), alertID) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, &AuthError{ + Status: http.StatusNotFound, Code: apierr.AlertNotFound, Message: "alert not found", + } + } + logger.Error("ws: alert lookup failed: %v", err) + return nil, &AuthError{ + Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error", + } + } + if !alarmtoken.EqualToken(alert.SubscribeToken, token) { + logger.Warn("ws: subscribe token mismatch alert=%s device-sub=%s", alert.ID, sub.ID) + return nil, &AuthError{ + Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token", + } + } + return &Identity{ + AlertID: alert.ID, + SubscriptionID: &sub.ID, + DeviceID: sub.DeviceID, + IsDevice: true, + }, nil +} + +// deviceTokenHash mirrors the service's device-token hash (plain SHA-256 hex): +// the WebSocket package verifies the same credential the actions accept, so the +// transform must be identical. +func deviceTokenHash(tok string) string { + sum := sha256.Sum256([]byte(tok)) + return hex.EncodeToString(sum[:]) +} diff --git a/src/realtime/client.go b/src/realtime/client.go index 362b8d6..f2c5da5 100644 --- a/src/realtime/client.go +++ b/src/realtime/client.go @@ -149,7 +149,13 @@ func (c *client) ackDelivery(eventID string) { // nothing to record and no drain to check. return } - if err := db.RecordDelivered(ctx, c.identity.AlertID, c.identity.UserID); err != nil { + // Anonymous subscribers have no account to record analytics against: the + // subscription row is the only identity, so keep it alive instead. + if c.identity.SubscriptionID != nil { + if err := db.TouchSubscription(ctx, *c.identity.SubscriptionID); err != nil { + c.hub.opts.Logf("ws: touch subscription alert=%s: %v", c.identity.AlertID, err) + } + } else if err := db.RecordDelivered(ctx, c.identity.AlertID, c.identity.UserID); err != nil { c.hub.opts.Logf("ws: delivered analytics alert=%s: %v", c.identity.AlertID, err) } if _, err := db.CompleteAlertDeliveries(ctx, c.identity.AlertID); err != nil { diff --git a/src/realtime/hub.go b/src/realtime/hub.go index d77941d..1cc2909 100644 --- a/src/realtime/hub.go +++ b/src/realtime/hub.go @@ -91,9 +91,12 @@ type ClientFrame struct { // Identity is the authenticated WebSocket caller. type Identity struct { - AlertID uuid.UUID + AlertID uuid.UUID + // UserID is uuid.Nil for an anonymous subscriber: a device token is not a + // user (contract rev 10). UserID uuid.UUID IsAdmin bool + IsDevice bool SubscriptionID *uuid.UUID DeviceID string }