Bỏ qua tới nội dung chính
Portfolio
MQTT & EMQX

EMQX: xác thực và phân quyền topic qua backend (HTTP)EMQX: authentication and topic permissions through your backend (HTTP)

Từng bước, ghi rõ làm ở VPS, Dashboard, code BE hay code FE / App: nối authn + authz vào API, bật TLS verify, chặn hết khi backend sập, kèm cách kiểm tra.Step by step, each one marked VPS, Dashboard, backend code or web / app code: wire authn + authz to the API, keep TLS verification on, fail closed when the backend is down, and check it.

Hai lần EMQX hỏi backendThe two times EMQX asks the backend

Appu_42EMQX:1883Backendauth · aclFile{deny, all}CONNECT · SUB/mqtt/auth · /mqtt/aclallow / denySUBACK 0x80deny

Kết nối thì hỏi /mqtt/auth một lần; mỗi lần subscribe hay publish lại hỏi /mqtt/acl. Backend không trả lời thì file {deny, all} chốt. Bấm ▶ để xem từng bước.Connecting asks /mqtt/auth once; every subscribe or publish asks /mqtt/acl. If the backend can't answer, the file's {deny, all} decides. Press ▶ to step through.

Bài này dựng một broker EMQX 5 cho một app có đăng nhập, sao cho:

  • chỉ client có danh tính hợp lệ mới kết nối được,
  • mỗi client chỉ đọc, ghi đúng topic của mình,
  • backend sập thì broker chặn hết, không mở toang.

Ví dụ xuyên suốt là app theo dõi đơn hàng ShopTrack. Backend chạy ở https://api.shoptrack.example, broker ở broker.shoptrack.example. User đăng nhập thì nhận JWT, rồi nhận cập nhật đơn hàng realtime qua MQTT.

ClientusernamepasswordSubscribePublish
Dịch vụ backendbackend-svcmật khẩu riêng (env)mọi topicmọi topic
User đã đăng nhập (web, app)user id, ví dụ u_42JWT access tokenorders/u_42orders/u_42/ack
Còn lạikhông gìkhông gì

Bạn sẽ làm việc ở 5 nơi

Mỗi mục bên dưới mở đầu bằng biểu tượng của nơi làm. Gặp biểu tượng nào thì mở đúng chỗ đó.

Nơi làmLà gì
🖥️VPSTerminal SSH vào server sẽ chạy broker
🎛️Dashboard EMQXTrình duyệt, trang quản trị của EMQX (cổng 18083)
⚙️Code BERepo backend: thêm route, thêm biến môi trường, rồi deploy
📱Code FE / AppRepo web và app mobile: chỗ kết nối MQTT
🧪Máy của bạnTerminal trên máy cá nhân, dùng để thử và kiểm tra

Làm theo đúng thứ tự này:

  1. 🖥️ Chạy EMQX, mở firewall (mục 2)
  2. 🎛️ Đăng nhập Dashboard, đổi mật khẩu (mục 3)
  3. ⚙️ Viết hai endpoint /mqtt/auth và /mqtt/acl, deploy (mục 4)
  4. 🎛️ Nối authn vào backend (mục 5)
  5. 🎛️ Nối authz vào backend, kéo lên đầu (mục 6)
  6. 🧪 + 🎛️ Lấy CA, dán vào TLS của hai nguồn (mục 7)
  7. 📱 Cho web và app kết nối đúng kiểu (mục 8)
  8. 🧪 Thử: wildcard phải bị từ chối (mục 10)
  9. 🎛️ Chuyển sang chặn hết khi backend sập (mục 9), rồi thử lại lần nữa

1. Hiểu trước: hai câu broker phải hỏi

📍 Chưa làm gì cả. Đọc để hiểu vì sao các bước sau lại như vậy.

  • Authentication (authn): "bạn là ai?" Hỏi một lần lúc CONNECT, có username và password.
  • Authorization (authz): "bạn được làm gì?" Hỏi mỗi lần SUBSCRIBE hoặc PUBLISH, có username, topic, action, nhưng không có password.

Authz không thấy password nên chỉ tin được username. Vì vậy bước authn phải ràng username với danh tính: JWT hợp lệ thôi chưa đủ, username còn phải bằng user id trong JWT. Bỏ qua bước này thì u_7 cầm JWT của chính mình, khai username = u_42, và authz sẽ cho đọc đơn của u_42.

⚠️ EMQX mới cài chỉ có authz kiểu file, dòng cuối là {allow, all}., kèm no_match = allow. File đó chỉ cấm đúng hai chuỗi # và +/#, nên client nào cũng subscribe được orders/+, tức là đọc đơn của mọi người. Chỉ bật authn là chưa đủ.

2. 🖥️ VPS: chạy EMQX

📍 Làm ở VPS: SSH vào server sẽ chạy broker.

bash
docker run -d --name emqx --restart unless-stopped \
  -p 1883:1883 -p 8883:8883 -p 8083:8083 -p 8084:8084 \
  -p 127.0.0.1:18083:18083 \
  emqx/emqx:5.8.6
CổngDùng choMở ra Internet?
1883 / 8883MQTT qua TCP / TLS (app mobile, server)Có
8083 / 8084MQTT qua WebSocket / WSS (trình duyệt), đường dẫn /mqttCó
18083Dashboard và REST APIKhông

127.0.0.1:18083 nghĩa là Dashboard chỉ nghe trên chính VPS. Lưu ý: cổng Docker publish đi vòng qua ufw, nên chặn bằng ufw không có tác dụng với 18083. Gắn vào 127.0.0.1 như trên mới chắc.

Mở firewall cho các cổng MQTT (nếu VPS dùng ufw):

bash
sudo ufw allow 1883/tcp
sudo ufw allow 8883/tcp
sudo ufw allow 8084/tcp

3. 🎛️ Dashboard: đăng nhập lần đầu

📍 Làm trên máy của bạn → trình duyệt. Dashboard không mở ra Internet, nên đi qua đường hầm SSH.

bash
# 🧪 Máy của bạn: giữ cửa sổ này mở trong lúc dùng Dashboard
ssh -L 18083:localhost:18083 user@broker.shoptrack.example

Mở http://localhost:18083, đăng nhập admin / public, đổi mật khẩu ngay. Mọi bước 🎛️ bên dưới đều làm ở đây.

4. ⚙️ Code BE: hai endpoint cho EMQX gọi

📍 Làm trong repo backend, rồi deploy. EMQX sẽ gọi hai route này qua HTTPS.

EMQX đọc {"result": "allow"} hoặc {"result": "deny"} trong body, với status 200. Status khác bị coi là lỗi và thành ignore. NestJS mặc định trả 201 cho POST, nên phải đặt @HttpCode(200).

ts
Loading...

Hãy so sánh bằng nhau, đừng so khớp mẫu. Khi subscribe, topic là nguyên chuỗi client gửi lên, kể cả wildcard. orders/+ không bằng orders/u_42 nên tự bị từ chối.

Backend cũng là một client MQTT: nó kết nối bằng backend-svc để đẩy tin cho user.

ts
Loading...

Biến môi trường cần thêm khi deploy: MQTT_SVC_PASSWORD (một chuỗi ngẫu nhiên dài, ví dụ openssl rand -hex 24).

5. 🎛️ Dashboard: nối authn vào backend

📍 Làm trên Dashboard: Access Control → Authentication → Create → Password-Based → HTTP Server.

  • Method POST, URL https://api.shoptrack.example/mqtt/auth
  • Header content-type: application/json
  • Body:
json
Loading...

${...} là chỗ EMQX tự điền khi gọi, giữ nguyên như trên. peerhost là IP của client. Có nó, backend mới giới hạn được số lần đoán mật khẩu theo IP.

6. 🎛️ Dashboard: nối authz vào backend

📍 Làm trên Dashboard: Access Control → Authorization → Create → HTTP Server.

  • Method POST, URL https://api.shoptrack.example/mqtt/acl
  • Body:
json
Loading...

Sau đó kéo nguồn HTTP lên đầu, trên nguồn File. EMQX hỏi các nguồn lần lượt theo thứ tự:

  • allow hoặc deny: chốt luôn, không hỏi nguồn sau.
  • ignore (backend lỗi, timeout, status lạ): hỏi tiếp nguồn kế.
  • Không nguồn nào chốt: dùng no_match.

7. 🧪 + 🎛️ TLS khi EMQX gọi backend: phải có CA

📍 Lấy CA trên máy của bạn, rồi dán vào Dashboard cho cả hai nguồn (authn ở mục 5, authz ở mục 6).

Bật TLS cho cả hai nguồn và giữ verify_peer, đừng tắt kiểm tra chứng chỉ cho xong việc. Có điều verify_peer mà không khai CA thì EMQX không kiểm được gì. Nguồn báo disconnected, mọi lượt hỏi thành ignore rồi rơi xuống nguồn sau. Dashboard không báo lỗi rõ ràng, chỉ thấy số ignore tăng dần.

Backend dùng Let's Encrypt thì lấy hai CA gốc của họ:

bash
Loading...

🎛️ Trên Dashboard, mở từng nguồn → phần TLS: dán nội dung le-roots.pem vào ô CA Cert, điền SNI là api.shoptrack.example, Verify để verify_peer. Khi đổi sang nhà cấp chứng chỉ khác thì nhớ thay CA ở đây.

8. 📱 Code FE / App: kết nối đúng kiểu

📍 Làm trong repo web và repo app. Broker giờ chỉ nhận đúng một kiểu đăng nhập: username là user id, password là JWT.

Web (trình duyệt) phải dùng WSS, vì trang HTTPS không được mở ws://:

ts
Loading...
dart
Loading...

Ba điều client phải tự lo:

  • Chỉ subscribe topic của mình. Subscribe orders/+ sẽ nhận SUBACK 0x80, không có tin nào về.
  • JWT hết hạn thì broker không tự đá client ra, vì authn chỉ hỏi lúc kết nối. Nhưng lần nối lại sau đó sẽ bị từ chối. Trước khi nối lại, lấy access token mới (refresh), rồi mới connect.
  • Trình duyệt cần listener wss có chứng chỉ. Gắn chứng chỉ cho wss:default (🎛️ Management → Listeners), hoặc đặt Nginx phía trước làm TLS.

9. 🎛️ Dashboard: chặn hết khi backend không trả lời (fail-closed)

📍 Làm trên Dashboard, và chỉ làm khi mục 5 đến 8 đã chạy đúng.

  1. Access Control → Authorization → nguồn File: sửa dòng {allow, all}. thành {deny, all}.
  2. Access Control → Authorization → Settings: đặt No Match là deny.

Từ giờ backend sập thì không ai subscribe hay publish được. Authn cũng đi qua backend nên lúc đó cũng chẳng ai kết nối mới được. Chặn thêm ở authz không làm hỏng gì thêm, chỉ bịt nốt khe hở cho các client đã kết nối từ trước.

Làm ngược thứ tự, tức chặn trước khi nguồn HTTP chạy được, là chặn sạch mọi client đang dùng.

10. 🧪 Kiểm tra

📍 Làm trên máy của bạn, cần gói mosquitto-clients. Lấy một JWT thật bằng cách đăng nhập app.

bash
Loading...

🎛️ Rồi xem số liệu của nguồn HTTP: Access Control → Authorization → HTTP Server → Metrics. Khi app chạy bình thường, allow phải tăng còn ignore phải đứng yên.

📱 Cuối cùng mở web và app thật: đăng nhập, tạo một đơn, xem cập nhật có về ngay không.

11. Bẫy hay gặp

  • ⚙️ Kết quả authz được cache 1 phút cho mỗi client (authorization.cache.ttl). Vừa sửa luật ở backend thì client đang kết nối có thể chưa thấy ngay.
  • 🎛️ Đọc cấu hình qua REST API, password bị che thành ******. Nếu ghi ngược nguyên body đó về (PUT), hãy điền lại "password": "${password}" cho chắc.
  • 🎛️ deny không ngắt kết nối (deny_action = ignore). Client nhận SUBACK mã 0x80 và vẫn ở lại. Muốn đá client ra thì đặt disconnect.
  • 🧪 Bật nguồn HTTP xong thì thử ngay bằng một user hợp lệ subscribe orders/+. Nếu vẫn được, nguồn HTTP đang ignore: xem lại mục 7.
  • 🖥️ Đừng chỉ dựa vào ufw để giấu Dashboard. Docker vượt qua nó, xem mục 2.

Hoàn tác

📍 Làm trên Dashboard.

Disable nguồn HTTP, đổi dòng cuối của file về {allow, all}. và No Match về allow. Broker trở lại như lúc mới cài.

This post sets up an EMQX 5 broker for an app with logins, so that:

  • only clients with a valid identity can connect,
  • each client reads and writes its own topics only,
  • when the backend is down, the broker blocks everything instead of opening up.

The running example is ShopTrack, an order-tracking app. The backend lives at https://api.shoptrack.example, the broker at broker.shoptrack.example. A signed-in user gets a JWT and receives order updates in real time over MQTT.

ClientusernamepasswordSubscribePublish
Backend servicebackend-svcits own password (env)any topicany topic
Signed-in user (web, app)the user id, e.g. u_42JWT access tokenorders/u_42orders/u_42/ack
Anyone elsenothingnothing

You will work in 5 places

Every section below starts with the icon of where it happens. See an icon, open that place.

WhereWhat it is
🖥️VPSAn SSH terminal on the server that will run the broker
🎛️EMQX DashboardA browser, on EMQX's admin page (port 18083)
⚙️Backend codeThe backend repo: add routes, add an env variable, deploy
📱Web / app codeThe web and mobile repos: where they connect to MQTT
🧪Your machineA terminal on your own computer, for trying things out

Do it in this order:

  1. 🖥️ Run EMQX, open the firewall (section 2)
  2. 🎛️ Sign in to the Dashboard, change the password (section 3)
  3. ⚙️ Write the two endpoints /mqtt/auth and /mqtt/acl, deploy (section 4)
  4. 🎛️ Point authn at the backend (section 5)
  5. 🎛️ Point authz at the backend, move it to the top (section 6)
  6. 🧪 + 🎛️ Fetch the CA, paste it into both sources' TLS (section 7)
  7. 📱 Make web and app connect the right way (section 8)
  8. 🧪 Test: a wildcard must be refused (section 10)
  9. 🎛️ Switch to blocking everything when the backend is down (section 9), then test again

1. First, understand the two questions a broker asks

📍 Nothing to do yet. Read this to see why the later steps look the way they do.

  • Authentication (authn): "who are you?" Asked once, at CONNECT, with username and password.
  • Authorization (authz): "what may you do?" Asked on every SUBSCRIBE or PUBLISH, with username, topic and action, but no password.

Authz never sees the password, so all it can trust is the username. That is why authn must bind the username to the identity: a valid JWT is not enough, the username must equal the user id inside it. Skip this and u_7 connects with their own JWT under username = u_42, and authz happily lets them read u_42's orders.

⚠️ A fresh EMQX ships with only a file authorizer ending in {allow, all}., plus no_match = allow. That file only forbids the exact strings # and +/#, so any client can still subscribe to orders/+ and read everyone's orders. Turning on authn alone is not enough.

2. 🖥️ VPS: run EMQX

📍 On the VPS: SSH into the server that will run the broker.

bash
docker run -d --name emqx --restart unless-stopped \
  -p 1883:1883 -p 8883:8883 -p 8083:8083 -p 8084:8084 \
  -p 127.0.0.1:18083:18083 \
  emqx/emqx:5.8.6
PortForOpen to the Internet?
1883 / 8883MQTT over TCP / TLS (mobile apps, servers)Yes
8083 / 8084MQTT over WebSocket / WSS (browsers), path /mqttYes
18083Dashboard and REST APINo

127.0.0.1:18083 means the Dashboard only listens on the VPS itself. Note that ports published by Docker bypass ufw, so a ufw rule does nothing for 18083. Binding to 127.0.0.1 as above is what actually hides it.

Open the MQTT ports in the firewall (if the VPS uses ufw):

bash
sudo ufw allow 1883/tcp
sudo ufw allow 8883/tcp
sudo ufw allow 8084/tcp

3. 🎛️ Dashboard: first sign-in

📍 On your machine → a browser. The Dashboard is not on the Internet, so go through an SSH tunnel.

bash
# 🧪 Your machine: keep this window open while you use the Dashboard
ssh -L 18083:localhost:18083 user@broker.shoptrack.example

Open http://localhost:18083, sign in with admin / public and change the password right away. Every 🎛️ step below happens here.

4. ⚙️ Backend code: two endpoints for EMQX to call

📍 In the backend repo, then deploy. EMQX will call these two routes over HTTPS.

EMQX reads {"result": "allow"} or {"result": "deny"} from the body, with status 200. Any other status counts as an error and becomes ignore. NestJS answers POST with 201 by default, so set @HttpCode(200).

ts
Loading...

Compare for equality, not with a pattern. On subscribe, topic is the exact filter the client sent, wildcards included. orders/+ is not equal to orders/u_42, so it is refused without any extra rule.

The backend is an MQTT client too: it connects as backend-svc to push messages to users.

ts
Loading...

Env variable to add on deploy: MQTT_SVC_PASSWORD (a long random string, e.g. openssl rand -hex 24).

5. 🎛️ Dashboard: point authn at the backend

📍 On the Dashboard: Access Control → Authentication → Create → Password-Based → HTTP Server.

  • Method POST, URL https://api.shoptrack.example/mqtt/auth
  • Header content-type: application/json
  • Body:
json
Loading...

${...} are placeholders EMQX fills in on each call; type them exactly as shown. peerhost is the client's IP. Without it the backend cannot rate-limit password guesses per IP.

6. 🎛️ Dashboard: point authz at the backend

📍 On the Dashboard: Access Control → Authorization → Create → HTTP Server.

  • Method POST, URL https://api.shoptrack.example/mqtt/acl
  • Body:
json
Loading...

Then drag the HTTP source to the top, above the File source. EMQX asks the sources in order:

  • allow or deny: final, later sources are not asked.
  • ignore (backend error, timeout, odd status): ask the next source.
  • Nobody decided: use no_match.

7. 🧪 + 🎛️ TLS from EMQX to the backend needs a CA

📍 Fetch the CA on your machine, then paste it on the Dashboard into both sources (authn from section 5, authz from section 6).

Turn TLS on for both sources and keep verify_peer; don't switch certificate checks off to make it work. But verify_peer without a CA file verifies nothing: the source shows disconnected, every check becomes ignore and falls through to the next source. The Dashboard shows no clear error, only a growing ignore count.

If the backend uses Let's Encrypt, fetch their two root CAs:

bash
Loading...

🎛️ On the Dashboard, open each source → TLS: paste le-roots.pem into CA Cert, set SNI to api.shoptrack.example, and leave Verify on verify_peer. Switch certificate vendors and you must replace the CA here.

8. 📱 Web / app code: connect the right way

📍 In the web repo and the app repo. The broker now accepts one kind of login only: username = the user id, password = the JWT.

The web must use WSS, because an HTTPS page may not open ws://:

ts
Loading...
dart
Loading...

Three things the client must handle itself:

  • Subscribe to its own topic only. Subscribing to orders/+ gets SUBACK 0x80 and no messages.
  • An expired JWT does not get the client kicked out, since authn is only asked at connect. But the next reconnect will be refused. Refresh the access token first, then connect.
  • Browsers need a wss listener with a certificate. Attach one to wss:default (🎛️ Management → Listeners), or put Nginx in front to do TLS.

9. 🎛️ Dashboard: block everything when the backend can't answer (fail-closed)

📍 On the Dashboard, and only once sections 5 to 8 work.

  1. Access Control → Authorization → File source: change {allow, all}. to {deny, all}.
  2. Access Control → Authorization → Settings: set No Match to deny.

Now a backend outage means nobody can subscribe or publish. Authn goes through the backend too, so nobody can connect during an outage anyway. Closing authz breaks nothing extra; it only shuts the gap for clients that were already connected.

Do it in the wrong order, blocking before the HTTP source works, and you lock out every client you have.

10. 🧪 Check it

📍 On your machine, with the mosquitto-clients package. Get a real JWT by signing in to the app.

bash
Loading...

🎛️ Then look at the HTTP source's numbers: Access Control → Authorization → HTTP Server → Metrics. With the app in normal use, allow should climb and ignore should stay put.

📱 Finally open the real web and app: sign in, create an order, and see the update arrive right away.

11. Common traps

  • ⚙️ Authz results are cached for 1 minute per client (authorization.cache.ttl). Right after changing a rule in the backend, connected clients may not see it yet.
  • 🎛️ The REST API masks the password as ****** when you read the config. If you PUT that body back, set "password": "${password}" again to be safe.
  • 🎛️ deny does not disconnect (deny_action = ignore). The client gets SUBACK code 0x80 and stays connected. Set disconnect to kick it out.
  • 🧪 Test as soon as the HTTP source is on: subscribe to orders/+ as a valid user. If that still works, the HTTP source is answering ignore; go back to section 7.
  • 🖥️ Don't rely on ufw alone to hide the Dashboard. Docker goes around it, see section 2.

Roll back

📍 On the Dashboard.

Disable the HTTP source, set the file's last rule back to {allow, all}. and No Match back to allow. The broker is back to its fresh-install state.