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.
232 lines
8.5 KiB
Go
232 lines
8.5 KiB
Go
// 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 realtime
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.dcntrlized.org/Dcntrlized/Alarm/db"
|
|
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken"
|
|
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
|
|
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
|
|
"git.dcntrlized.org/Dcntrlized/Alarm/logger"
|
|
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// authenticateWS resolves a GET /ws/alarm upgrade request:
|
|
//
|
|
// ?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
|
|
// clients (and debugging tools) that cannot set headers on the upgrade; that is
|
|
// a convenience, not a second trust path — the token is verified identically.
|
|
//
|
|
// A subscribe token authorises only a user who actually has a subscription on
|
|
// 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"))
|
|
}
|
|
if access == "" {
|
|
access = strings.TrimSpace(r.URL.Query().Get("access_token"))
|
|
}
|
|
if access == "" {
|
|
return nil, &AuthError{
|
|
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "missing access token",
|
|
}
|
|
}
|
|
|
|
authReq := r.Clone(r.Context())
|
|
authReq.Header.Set("X-Access-Token", access)
|
|
p, err := authclient.Authorize(authReq)
|
|
if err != nil {
|
|
logger.Warn("ws: access token rejected: %v", err)
|
|
return nil, &AuthError{
|
|
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid access token",
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
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",
|
|
}
|
|
}
|
|
|
|
// Admin path: the alert's own admin token, presented by the alert's admin.
|
|
if alarmtoken.EqualHash(alert.AdminTokenHash, token) {
|
|
if alert.AdminUserID != p.ID {
|
|
logger.Warn("ws: user=%s presented the admin token of alert=%s without owning it", p.ID, alert.ID)
|
|
return nil, &AuthError{
|
|
Status: http.StatusForbidden, Code: apierr.AlertNotAdmin, Message: "caller is not the alert admin",
|
|
}
|
|
}
|
|
return &Identity{AlertID: alert.ID, UserID: p.ID, IsAdmin: true}, nil
|
|
}
|
|
|
|
// Subscriber path: the shared subscribe token plus a real subscription.
|
|
if !alarmtoken.EqualToken(alert.SubscribeToken, token) {
|
|
logger.Warn("ws: token mismatch alert=%s user=%s", alert.ID, p.ID)
|
|
return nil, &AuthError{
|
|
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token",
|
|
}
|
|
}
|
|
sub, err := resolveSubscription(r, alert.ID, p.ID)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
return nil, &AuthError{
|
|
Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "no subscription for this user on the alert",
|
|
}
|
|
}
|
|
logger.Error("ws: subscription lookup failed: %v", err)
|
|
return nil, &AuthError{
|
|
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
|
|
}
|
|
}
|
|
return &Identity{
|
|
AlertID: alert.ID,
|
|
UserID: p.ID,
|
|
SubscriptionID: &sub.ID,
|
|
DeviceID: sub.DeviceID,
|
|
}, nil
|
|
}
|
|
|
|
// resolveSubscription picks the device row a connection keeps alive: the one
|
|
// named by ?deviceId= when given, otherwise the user's oldest subscription on
|
|
// the alert.
|
|
func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscription, error) {
|
|
if deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")); deviceID != "" {
|
|
// The client sends the raw value; the row stores its digest.
|
|
return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceid.Hash(deviceID))
|
|
}
|
|
ids, err := db.UserSubscriptionIDs(r.Context(), alertID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, db.ErrNotFound
|
|
}
|
|
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[:])
|
|
}
|