Files
Alarm/src/realtime/auth.go
T
Admin 0e24c4e4f0 [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.
2026-10-19 18:37:17 +01:00

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[:])
}