This commit is contained in:
2026-10-18 22:31:16 +01:00
commit ec38e680d6
64 changed files with 7222 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// 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 (
"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/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
//
// 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) {
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 != "" {
return db.GetSubscriptionByDevice(r.Context(), alertID, userID, 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])
}