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) }