[feat](alarm): let devices subscribe without an account, with a token this service mints

Receiving an alarm should not require signing up, and it should not require
pretending to be a user either: an anonymous subscriber is not an account, it
is a subscription. The row is the identity.

joinAlert now takes NO access token. The subscribe token from the QR is the
authorisation, and the service answers with a deviceToken (32 CSPRNG bytes,
base64url, stored only as SHA-256) returned exactly once. Later subscriber
calls send it as X-Device-Token (?deviceToken= on the WebSocket upgrade, which
cannot set headers): getAlert, alarmStatus, ackAlarm, removeSubscription,
qrImage and GET /ws/alarm.

The credential is deliberately narrow. It names one subscription on one alert,
so an ack addresses the ledger row directly and 'am I subscribed' is a field
comparison. createAlert/triggerAlert/disarmAlert/deleteAlert/listSubscriptions
never see it, so they keep answering 401 for a subscriber without any new gate:
owning an alert still needs an account, and the admin token stays the only
secret that can ring or silence one.

Re-joining from the same device rotates the token (one row per alert+device),
so a lost credential is recoverable with the QR and a leaked one dies on the
next join. ALARM_ANONYMOUS=false puts joinAlert back behind login and stores the
subscription against the account, exactly like rev 9.

Migration 010 is additive and idempotent: subscriber_user_id becomes nullable,
device_token_hash gets a unique index, and alarm_events.actor_user_id admits
NULL because an anonymous join logs an actor-less event.

Verified through Maestro against the dev stack (full table in
planning/platform/anonymous-access/plan.md): join with no token -> 200 and the
auth.users count does not move; getAlert/alarmStatus/ackAlarm with the token;
trigger -> 409 with the frame -> ack acked:1 destroyed:true; WS 101 with both
credentials (403 wrong subscribe token, 401 bad device token); old token 401
after a re-join; admin actions 401.
This commit is contained in:
2026-10-19 18:37:17 +01:00
parent 914fd6570d
commit 0e24c4e4f0
15 changed files with 777 additions and 98 deletions
+42 -4
View File
@@ -213,7 +213,7 @@ alert shapes documented here).
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `me` | GET | `GET /auth/me` | — (access token header only) | | `me` | GET | `GET /auth/me` | — (access token header only) |
| `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op | | `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": "…" }` | | `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` |
| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` | | `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` |
| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` | | `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 login/register (raw body, no envelope). These `/action/` variants are the
envelope-wrapped form of the same operations. 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` ## `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. 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 ```json
{ "payload": { { "payload": {
@@ -727,6 +761,7 @@ The service path is:
``` ```
/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>] /ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]
/ws/alarm?alertId=<uuid>&token=<subscribeToken>&deviceToken=<deviceToken>
``` ```
Through an instance (the supported path) that is 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 end, so the 20s ping / 60s pong accounting below is measured between client and
service, not against the proxy. service, not against the proxy.
The socket is **authenticated, not anonymous**: the access token identifies the The socket is **authenticated, not anonymous**: one of two credentials
user and the second token proves the relationship to the alert. 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 ### Authentication
+75
View File
@@ -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 <https://www.gnu.org/licenses/>.
-- 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 $$;
+45 -12
View File
@@ -54,7 +54,7 @@ type ackAlarmResponse struct {
// alert no longer exists, which is the intended outcome, not a failure. // alert no longer exists, which is the intended outcome, not a failure.
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) { func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
const action = "ackAlarm" const action = "ackAlarm"
p, ok := principal(w, r, action) c, ok := callerFor(w, r, action)
if !ok { if !ok {
return return
} }
@@ -79,7 +79,13 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err) dbError(w, action, err)
return return
} }
allowed, err := isAdminOrSubscriber(r, alert, p.ID) 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 { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -88,25 +94,52 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return return
} }
}
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()} resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
// The owner has no delivery row (only subscribers are owed alarms), so an // A device token lives on the subscription, so the ledger row is addressed
// owner ack is a well-formed no-op rather than a validation error. // directly: no user id, no device-id echo, nothing to spoof.
if alert.AdminUserID != p.ID { if c.IsDevice {
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId")) cleared, err := db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID)
if err != nil {
validationError(w, "%v", err)
return
}
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, p.ID, deviceid.Hash(raw), eventID)
if err != nil { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
} }
resp.Acked = cleared resp.Acked = cleared
if cleared > 0 { 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) dbError(w, action, err)
return return
} }
+12 -3
View File
@@ -281,7 +281,7 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
// admin and by any subscribed user. // admin and by any subscribed user.
func GetAlert(w http.ResponseWriter, r *http.Request, payload any) { func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "getAlert" const action = "getAlert"
p, ok := principal(w, r, action) c, ok := callerFor(w, r, action)
if !ok { if !ok {
return return
} }
@@ -296,9 +296,17 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err) dbError(w, action, err)
return return
} }
isAdmin := res.Alert.AdminUserID == p.ID isAdmin := !c.IsDevice && res.Alert.AdminUserID == c.UserID
if !isAdmin { if !isAdmin {
allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID) 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 { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -308,6 +316,7 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
return return
} }
} }
}
count, err := db.CountSubscriptions(r.Context(), alertID) count, err := db.CountSubscriptions(r.Context(), alertID)
if err != nil { if err != nil {
dbError(w, action, err) dbError(w, action, err)
+151
View File
@@ -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 <https://www.gnu.org/licenses/>.
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")
}
}
+102
View File
@@ -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 <https://www.gnu.org/licenses/>.
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[:])
}
+12
View File
@@ -27,6 +27,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -108,6 +109,17 @@ func principal(w http.ResponseWriter, r *http.Request, action string) (*authclie
return p, true 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. // decodePayload unmarshals the {payload} wrapper's inner value into dst.
// //
// Unknown fields are deliberately ignored: the clients are shipped separately // Unknown fields are deliberately ignored: the clients are shipped separately
+25 -3
View File
@@ -187,7 +187,7 @@ type statusView struct {
// background job is needed to expire anything. // background job is needed to expire anything.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus" const action = "alarmStatus"
p, ok := principal(w, r, action) c, ok := callerFor(w, r, action)
if !ok { if !ok {
return return
} }
@@ -202,7 +202,13 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err) dbError(w, action, err)
return return
} }
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID) 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 { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -211,6 +217,7 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return return
} }
}
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer // Offline check-in, READ-ONLY. If this device still owes an alarm, answer
// with the frame it missed instead of a quiet 200 — and clear NOTHING. The // with the frame it missed instead of a quiet 200 — and clear NOTHING. The
@@ -218,15 +225,30 @@ 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 // 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 // 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). // that case falls through to the normal body (active:false).
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 := "" deviceHash := ""
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" { if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw) deviceHash = deviceid.Hash(raw)
} }
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash) var err error
pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err) dbError(w, action, err)
return return
} }
}
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger { if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
writeOfflineAlarm(w, res.Alert, pending) writeOfflineAlarm(w, res.Alert, pending)
return return
+9 -2
View File
@@ -40,7 +40,7 @@ const (
// through createAlert/qrPayload, the subscriber through the token they scanned). // through createAlert/qrPayload, the subscriber through the token they scanned).
func QRImage(w http.ResponseWriter, r *http.Request, payload any) { func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
const action = "qrImage" const action = "qrImage"
p, ok := principal(w, r, action) c, ok := callerFor(w, r, action)
if !ok { if !ok {
return return
} }
@@ -55,7 +55,13 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err) dbError(w, action, err)
return return
} }
allowed, err := isAdminOrSubscriber(r, alert, p.ID) 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 { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
@@ -64,6 +70,7 @@ func QRImage(w http.ResponseWriter, r *http.Request, payload any) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return return
} }
}
scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale) scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale)
if err != nil { if err != nil {
+100 -22
View File
@@ -28,7 +28,7 @@ import (
type subscriptionView struct { type subscriptionView struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
DeviceLabel string `json:"deviceLabel,omitempty"` DeviceLabel string `json:"deviceLabel,omitempty"`
Platform string `json:"platform,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 // schema, and the Alarm service does not read another service's tables. The
// field is omitted rather than sent empty. // field is omitted rather than sent empty.
func newSubscriptionView(s *db.Subscription) subscriptionView { func newSubscriptionView(s *db.Subscription) subscriptionView {
return subscriptionView{ view := subscriptionView{
ID: s.ID.String(), ID: s.ID.String(),
UserID: s.SubscriberUserID.String(),
DeviceLabel: deref(s.DeviceLabel), DeviceLabel: deref(s.DeviceLabel),
Platform: deref(s.Platform), Platform: deref(s.Platform),
Enabled: s.Enabled, Enabled: s.Enabled,
LastSeenAt: rfc3339Ptr(s.LastSeenAt), LastSeenAt: rfc3339Ptr(s.LastSeenAt),
Created: rfc3339(s.Created), 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 -------------------------------------------------------------- // ---- 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 { type joinRequest struct {
AlertID string `json:"alertId"` AlertID string `json:"alertId"`
SubscribeToken string `json:"subscribeToken"` 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. // race, where two inserts for the same device hit the unique index at once.
func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) { func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "joinAlert" const action = "joinAlert"
p, ok := principal(w, r, action)
if !ok {
return
}
var req joinRequest var req joinRequest
if err := decodePayload(payload, &req); err != nil { 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) dbError(w, action, err)
return 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)) { 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") writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token")
return 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 { switch {
case err == nil: case err == nil:
Debug(action, "POST", "idempotent re-join alert=%s sub=%s", alertID, existing.ID) token, terr := mintDeviceToken()
writeJSON(w, http.StatusOK, newSubscriptionView(existing)) 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 return
case !errors.Is(err, db.ErrNotFound): case !errors.Is(err, db.ErrNotFound):
dbError(w, action, err) dbError(w, action, err)
return return
} }
token, err := mintDeviceToken()
if err != nil {
dbError(w, action, err)
return
}
tokenHash := hashDeviceToken(token)
sub, err := db.CreateSubscription(r.Context(), &db.Subscription{ sub, err := db.CreateSubscription(r.Context(), &db.Subscription{
AlertID: alertID, AlertID: alertID,
SubscriberUserID: p.ID,
DeviceID: storedDeviceID, DeviceID: storedDeviceID,
DeviceLabel: label, DeviceLabel: label,
Platform: platform, Platform: platform,
DeviceTokenHash: &tokenHash,
}) })
if errors.Is(err, db.ErrConflict) { if errors.Is(err, db.ErrConflict) {
// Two concurrent joins for the same device: the sequential repeat path // Two concurrent joins for the same device: the sequential repeat path
// above already handled the normal re-scan case. // 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") writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert")
return return
} }
@@ -162,7 +200,42 @@ func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) {
return 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)) 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. // alert's admin may remove any device; a subscriber may remove their own.
func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) { func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
const action = "removeSubscription" const action = "removeSubscription"
p, ok := principal(w, r, action) c, ok := callerFor(w, r, action)
if !ok { if !ok {
return return
} }
@@ -237,17 +310,22 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
return return
} }
if sub.SubscriberUserID != p.ID { // A device token is scoped to its own subscription: it can remove that row
// Not the owner: only the alert admin may remove someone else's device. // and nothing else. An account may remove its own device, or any device on
// A stranger gets the same code as a missing row so the endpoint never // an alert it administers.
// confirms that the subscription exists. 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) alert, err := db.GetAlert(r.Context(), alertID)
if err != nil { if err != nil {
dbError(w, action, err) dbError(w, action, err)
return return
} }
if alert.AdminUserID != p.ID { if alert.AdminUserID != c.UserID {
Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID) Warn(action, "auth", "user=%s may not remove sub=%s", c.UserID, subscriptionID)
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found") writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found")
return return
} }
@@ -257,7 +335,7 @@ func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) {
dbError(w, action, err) dbError(w, action, err)
return 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{ writeJSON(w, http.StatusOK, map[string]any{
"alertId": alertID.String(), "alertId": alertID.String(),
"subscriptionId": subscriptionID.String(), "subscriptionId": subscriptionID.String(),
+7 -1
View File
@@ -30,6 +30,8 @@ type Event struct {
ID uuid.UUID ID uuid.UUID
AlertID uuid.UUID AlertID uuid.UUID
Kind string 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 ActorUserID uuid.UUID
Message *string Message *string
CreatedAt time.Time 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) { func scanEvent(row pgx.Row) (*Event, error) {
var e Event 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
+63 -7
View File
@@ -25,6 +25,12 @@ import (
) )
// Subscription is one alarm.subscriptions row: a device that receives the alarm. // 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 { type Subscription struct {
ID uuid.UUID ID uuid.UUID
AlertID uuid.UUID AlertID uuid.UUID
@@ -35,15 +41,22 @@ type Subscription struct {
Enabled bool Enabled bool
LastSeenAt *time.Time LastSeenAt *time.Time
Created 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, 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) { func scanSubscription(row pgx.Row) (*Subscription, error) {
var s Subscription var s Subscription
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel, var user *uuid.UUID
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created) 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -65,19 +78,24 @@ func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, er
} }
defer func() { _ = tx.Rollback(ctx) }() defer func() { _ = tx.Rollback(ctx) }()
var actor *uuid.UUID
if s.SubscriberUserID != uuid.Nil {
actor = &s.SubscriberUserID
}
const q = `INSERT INTO alarm.subscriptions const q = `INSERT INTO alarm.subscriptions
(alert_id, subscriber_user_id, device_id, device_label, platform) (alert_id, subscriber_user_id, device_id, device_label, platform, device_token_hash)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING ` + subscriptionColumns RETURNING ` + subscriptionColumns
row, err := scanSubscription(tx.QueryRow(ctx, q, 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) { if isUniqueViolation(err) {
return nil, ErrConflict return nil, ErrConflict
} }
if err != nil { if err != nil {
return nil, err 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 return nil, err
} }
if err := tx.Commit(ctx); err != nil { 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)) 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. // GetSubscription fetches one subscription scoped to its alert.
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) { func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
const q = `SELECT ` + subscriptionColumns + ` const q = `SELECT ` + subscriptionColumns + `
+81
View File
@@ -16,6 +16,8 @@
package realtime package realtime
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
@@ -34,6 +36,8 @@ import (
// ?alertId=<uuid> required // ?alertId=<uuid> required
// &token=<secret> required; the alert's admin token OR its subscribe token // &token=<secret> required; the alert's admin token OR its subscribe token
// &deviceId=<id> optional; picks which device row is kept alive // &deviceId=<id> optional; picks which device row is kept alive
// &deviceToken=<t> 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 // 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 // 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 // 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. // token is only honoured for the user the alert was created by.
func authenticateWS(r *http.Request) (*Identity, error) { 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")) access := strings.TrimSpace(r.Header.Get("X-Access-Token"))
if access == "" { if access == "" {
access = strings.TrimSpace(r.URL.Query().Get("accessToken")) 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]) 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[:])
}
+7 -1
View File
@@ -149,7 +149,13 @@ func (c *client) ackDelivery(eventID string) {
// nothing to record and no drain to check. // nothing to record and no drain to check.
return 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) c.hub.opts.Logf("ws: delivered analytics alert=%s: %v", c.identity.AlertID, err)
} }
if _, err := db.CompleteAlertDeliveries(ctx, c.identity.AlertID); err != nil { if _, err := db.CompleteAlertDeliveries(ctx, c.identity.AlertID); err != nil {
+3
View File
@@ -92,8 +92,11 @@ type ClientFrame struct {
// Identity is the authenticated WebSocket caller. // Identity is the authenticated WebSocket caller.
type Identity struct { 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 UserID uuid.UUID
IsAdmin bool IsAdmin bool
IsDevice bool
SubscriptionID *uuid.UUID SubscriptionID *uuid.UUID
DeviceID string DeviceID string
} }