diff --git a/internal/service/api/handler/admin.go b/internal/service/api/handler/admin.go index 455b76a..e969bfe 100644 --- a/internal/service/api/handler/admin.go +++ b/internal/service/api/handler/admin.go @@ -7,6 +7,7 @@ package handler import ( "context" + "crypto/subtle" "net/http" "strings" "time" @@ -19,8 +20,11 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) { return } + // Constant-time сравнение токена: обычное != давало утечку по времени (ревью 2026-10-10). authHeader := r.Header.Get("Authorization") - if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != h.Cfg.AdminStatsToken { + token := strings.TrimPrefix(authHeader, "Bearer ") + if !strings.HasPrefix(authHeader, "Bearer ") || + subtle.ConstantTimeCompare([]byte(token), []byte(h.Cfg.AdminStatsToken)) != 1 { writeJSON(w, http.StatusUnauthorized, errResp("unauthorized")) return } diff --git a/internal/service/api/handler/mqtt_auth.go b/internal/service/api/handler/mqtt_auth.go deleted file mode 100644 index 1288d11..0000000 --- a/internal/service/api/handler/mqtt_auth.go +++ /dev/null @@ -1,167 +0,0 @@ -// mqtt_auth.go — HTTP-auth и ACL для EMQX (MQTT CONNECT / pub-sub). -// -// Endpoints (без JWT, вызываются EMQX внутри платформы): -// -// POST /internal/mqtt/auth — аутентификация: всегда HTTP 200, {"result":"allow"|"deny"} -// POST /internal/mqtt/acl — авторизация pub/sub -// -// Источник устройств — таблица iot_devices (не k8s Secrets). -package handler - -import ( - "crypto/subtle" - "encoding/json" - "errors" - "net/http" - "strings" - - "gitea.services.ngcloud.ru/Nail/IoT/internal/service/store" -) - -// mqttAuthRequest — тело запроса от EMQX 5.x (username, password, clientid, peerhost). -type mqttAuthRequest struct { - Username string `json:"username"` - Password string `json:"password"` - ClientID string `json:"clientid"` - PeerHost string `json:"peerhost"` -} - -// mqttAuthResponse — ответ для EMQX (всегда HTTP 200). -type mqttAuthResponse struct { - Result string `json:"result"` - ACL []aclRule `json:"acl,omitempty"` -} - -// aclRule — правило ACL для EMQX HTTP auth. -type aclRule struct { - Permission string `json:"permission"` - Action string `json:"action"` - Topic string `json:"topic"` -} - -// mqttAclRequest — тело запроса ACL от EMQX. -type mqttAclRequest struct { - Username string `json:"username"` - ClientID string `json:"clientid"` - Action string `json:"action"` - Topic string `json:"topic"` -} - -// MQTTAuth — POST /internal/mqtt/auth. -// -// Логика: -// 1. bridge-креды (MQTT_USERNAME/MQTT_PASSWORD из env) — проверяются до парсинга; -// 2. username "{ns}_{deviceId}" → поиск устройства в iot_devices; -// 3. constant-time сравнение пароля; проверка enabled; -// 4. best-effort last_connected; ACL по топику устройства. -func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) { - var req mqttAuthRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - - // Внутренний bridge-клиент: фиксированные креды, ACL выдаётся ниже. - if h.Cfg.MQTTUsername != "" && req.Username == h.Cfg.MQTTUsername { - if subtle.ConstantTimeCompare([]byte(h.Cfg.MQTTPassword), []byte(req.Password)) == 1 { - writeJSON(w, http.StatusOK, mqttAuthResponse{ - Result: "allow", - ACL: bridgeACLRules(), - }) - return - } - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - - // Парсим username: "{namespace}_{deviceId}" (первый '_' — разделитель). - idx := strings.Index(req.Username, "_") - if idx <= 0 || idx == len(req.Username)-1 { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - ns := req.Username[:idx] - deviceID := req.Username[idx+1:] - - device, err := h.Devices.GetByDeviceID(r.Context(), ns, deviceID) - if err != nil { - if !errors.Is(err, store.ErrNotFound) { - h.Log.Error("mqtt auth: device lookup", "namespace", ns, "device", deviceID, "err", err) - } - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - if !device.Enabled { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - if subtle.ConstantTimeCompare([]byte(device.MQTTPassword), []byte(req.Password)) != 1 { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - - // best-effort: обновляем время последнего подключения - h.Devices.TouchLastConnected(r.Context(), ns, deviceID) - - writeJSON(w, http.StatusOK, mqttAuthResponse{ - Result: "allow", - ACL: deviceACLRules(ns, deviceID), - }) -} - -// MQTTAcl — POST /internal/mqtt/acl. -// -// Bridge (clientID из env MQTT_CLIENT_ID): только subscribe. -// Устройство ({ns}_{deviceId}): pub/sub в своём топике. -func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) { - var req mqttAclRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - - if req.ClientID == h.Cfg.MQTTClientID { - if req.Action == "subscribe" { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) - return - } - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - - idx := strings.Index(req.Username, "_") - if idx <= 0 || idx == len(req.Username)-1 { - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - return - } - ns := req.Username[:idx] - deviceID := req.Username[idx+1:] - deviceTopic := ns + "/telemetry/" + deviceID - - switch { - case req.Action == "publish" && req.Topic == deviceTopic: - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) - case req.Action == "subscribe" && (req.Topic == deviceTopic || req.Topic == "#"): - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) - default: - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) - } -} - -// bridgeACLRules — ACL для внутреннего bridge: subscribe на все telemetry-топики. -func bridgeACLRules() []aclRule { - return []aclRule{ - {Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"}, - {Permission: "deny", Action: "all", Topic: "#"}, - } -} - -// deviceACLRules — ACL для устройства: только свой топик телеметрии. -func deviceACLRules(ns, deviceID string) []aclRule { - topic := ns + "/telemetry/" + deviceID - return []aclRule{ - {Permission: "allow", Action: "publish", Topic: topic}, - {Permission: "allow", Action: "subscribe", Topic: topic}, - {Permission: "deny", Action: "all", Topic: "#"}, - } -}