From 3b7c8a0a63026a5ec22aa3090bc70d19b41fd629 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sat, 10 Oct 2026 19:21:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(iot):=20bcrypt-=D1=85=D1=8D=D1=88=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20=D0=B8=20=D0=B1=D1=80=D0=B8?= =?UTF-8?q?=D0=B4=D0=B6=D0=B0=20+=20EMQX=20=D0=BD=D0=B0=20bcrypt=20(P0-3?= =?UTF-8?q?=20=D1=87.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handler CreateIoTDevice: пароль устройства хранится bcrypt-хэшем (hashPassword), открытый пароль отдаётся один раз в 201. - store.EnsureBridgeDevice: пароль бриджа тоже bcrypt-хэшем. - emqx/emqx.conf: password_hash_algorithm name=bcrypt, salt_rounds=10 (подтверждено по emqx/emqx: HOCON-ключ salt_rounds; Go bcrypt.DefaultCost=10). - Добавлена зависимость golang.org/x/crypto v0.42.0. --- emqx/emqx.conf | 4 ++-- go.mod | 1 + go.sum | 2 ++ internal/service/api/handler/devices.go | 24 +++++++++++++++++++++++- internal/service/store/bridge.go | 12 ++++++++++-- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/emqx/emqx.conf b/emqx/emqx.conf index 9914b27..8115376 100644 --- a/emqx/emqx.conf +++ b/emqx/emqx.conf @@ -40,8 +40,8 @@ authentication = [ verify = verify_none } password_hash_algorithm { - name = plain - salt_position = disable + name = bcrypt + salt_rounds = 10 } query = "SELECT mqtt_password AS password_hash FROM iot_devices WHERE enabled = true AND (namespace || '_' || device_id = ${username} OR (namespace = '__bridge' AND ${username} = device_id)) LIMIT 1" } diff --git a/go.mod b/go.mod index e4b9e8f..c2ebeaa 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/lib/pq v1.11.2 + golang.org/x/crypto v0.42.0 ) require ( diff --git a/go.sum b/go.sum index e8d71a1..81a845f 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= diff --git a/internal/service/api/handler/devices.go b/internal/service/api/handler/devices.go index 5a26b97..1e88604 100644 --- a/internal/service/api/handler/devices.go +++ b/internal/service/api/handler/devices.go @@ -18,6 +18,8 @@ import ( "strconv" "time" + "golang.org/x/crypto/bcrypt" + "gitea.services.ngcloud.ru/Nail/IoT/internal/service/store" ) @@ -110,6 +112,17 @@ func generateMQTTPassword() (string, error) { return "", lastErr } +// hashPassword — bcrypt-хэш пароля устройства. EMQX (postgres-auth) проверяет +// bcrypt (см. emqx/emqx.conf password_hash_algorithm). В БД хранится ТОЛЬКО хэш; +// открытый пароль отдаётся один раз при создании устройства. +func hashPassword(plain string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(h), nil +} + // identRe — допустимый charset идентификаторов (namespace/name/device_id): // только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод. // Причины запретов (ревью 2026-10-10): @@ -177,6 +190,15 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { return } + // В БД храним bcrypt-хэш (EMQX проверяет bcrypt — emqx/emqx.conf). Открытый + // пароль отдаём ОДИН раз в ответе 201 (иначе его негде взять). + hash, err := hashPassword(password) + if err != nil { + h.Log.Error("hash device password", "err", err) + writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials")) + return + } + phase := "Active" if !enabled { phase = "Disabled" @@ -187,7 +209,7 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { Name: req.Name, DeviceID: req.DeviceID, Enabled: enabled, - MQTTPassword: password, + MQTTPassword: hash, Metadata: req.Metadata, Phase: phase, } diff --git a/internal/service/store/bridge.go b/internal/service/store/bridge.go index e87fbac..010e3c4 100644 --- a/internal/service/store/bridge.go +++ b/internal/service/store/bridge.go @@ -3,6 +3,8 @@ package store import ( "context" "fmt" + + "golang.org/x/crypto/bcrypt" ) // bridgeNamespace — служебный namespace строки бриджа в iot_devices. @@ -17,14 +19,20 @@ const bridgeNamespace = "__bridge" // Нужно для PG-auth EMQX (хвост 3): EMQX зависит только от PostgreSQL, // монолит при старте гарантирует наличие строки бриджа. func (s *DeviceStore) EnsureBridgeDevice(ctx context.Context, username, password string) error { - _, err := s.db.ExecContext(ctx, ` + // Пароль бриджа храним bcrypt-хэшем (EMQX проверяет bcrypt), как и пароли + // устройств. + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("store: hash bridge password: %w", err) + } + _, err = s.db.ExecContext(ctx, ` INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase) VALUES ($1, $2, $3, true, $4, '{}'::jsonb, 'Active') ON CONFLICT (namespace, name) DO UPDATE SET device_id = EXCLUDED.device_id, enabled = true, mqtt_password = EXCLUDED.mqtt_password`, - bridgeNamespace, "iot-bridge", username, password) + bridgeNamespace, "iot-bridge", username, string(hash)) if err != nil { return fmt.Errorf("store: ensure bridge device: %w", err) }