[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:
+52
-19
@@ -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
|
||||
}
|
||||
|
||||
+19
-10
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+39
-17
@@ -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)
|
||||
|
||||
+16
-9
@@ -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)
|
||||
|
||||
+104
-26
@@ -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(),
|
||||
|
||||
+10
-4
@@ -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
|
||||
}
|
||||
|
||||
+63
-7
@@ -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 + `
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -34,6 +36,8 @@ import (
|
||||
// ?alertId=<uuid> required
|
||||
// &token=<secret> required; the alert's admin token OR its subscribe token
|
||||
// &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
|
||||
// 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[:])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user