[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(),
|
||||
|
||||
Reference in New Issue
Block a user