init
This commit is contained in:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user