From 9aee5f4100c05705d873bcfeaf96332be31e7399 Mon Sep 17 00:00:00 2001 From: HiepLM Date: Tue, 16 Jun 2026 09:57:55 +0700 Subject: [PATCH] update function login --- CMakeLists.txt | 2 + README.md | 15 + data/auth.json | 76 ++ docs/Reference_guide.md | 158 +++- scripts/test/smoke.sh | 42 +- src/app/lidar_manager_app.cpp | 8 + src/auth/auth_service.cpp | 776 ++++++++++++++++++ src/auth/auth_service.hpp | 79 ++ src/util/crypto_util.cpp | 143 ++++ src/util/crypto_util.hpp | 16 + ...pi_integration.cpython-38-pytest-8.3.5.pyc | Bin 14808 -> 18822 bytes tests/test_api_integration.py | 43 + www/app.js | 42 +- www/auth.js | 372 +++++++++ www/dashboard.js | 8 +- www/index.html | 123 ++- www/integrations.js | 11 +- www/missions.js | 17 +- www/style.css | 405 +++++++++ 19 files changed, 2272 insertions(+), 64 deletions(-) create mode 100644 data/auth.json create mode 100644 src/auth/auth_service.cpp create mode 100644 src/auth/auth_service.hpp create mode 100644 src/util/crypto_util.cpp create mode 100644 src/util/crypto_util.hpp create mode 100644 www/auth.js diff --git a/CMakeLists.txt b/CMakeLists.txt index 179aaf1..7bb4535 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,8 @@ add_executable(lidar_manager_web src/util/string_util.cpp src/util/id_util.cpp src/util/http_util.cpp + src/util/crypto_util.cpp + src/auth/auth_service.cpp src/domain/layout_schema.cpp src/domain/layout_profile.cpp src/storage/state_repository.cpp diff --git a/README.md b/README.md index 7a6cd55..978da9d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,21 @@ Hoặc chỉ định: Mở trình duyệt: `http://localhost:8080/` +### Đăng nhập (Signing in — MiR §2.1) + +Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (`data/auth.json`): + +| User | Password | Nhóm | +|------|----------|------| +| Admin | admin | Administrators (full quyền) | +| User | user | Users (dashboard write, còn lại read) | +| Distributor | distributor | Distributors (full quyền) | + +PIN 4 chữ số chỉ dùng được với user thuộc nhóm **Users** sau khi admin gán PIN (`PUT /api/users/:id`). + +Tắt auth cho dev/test: `LM_AUTH_DISABLED=1 ./build/lidar_manager_web …` +Tài liệu đầy đủ: [`docs/Reference_guide.md` §2.1](docs/Reference_guide.md#21-signing-in). + ## Docker (giới hạn 2 CPU, 4 GB RAM) Mô phỏng cấu hình controller tối thiểu SICK (Dual-Core, 4 GB) trên máy dev: diff --git a/data/auth.json b/data/auth.json new file mode 100644 index 0000000..50bb09c --- /dev/null +++ b/data/auth.json @@ -0,0 +1,76 @@ +{ + "groups": [ + { + "allow_pin": false, + "id": "group_distributors", + "name": "Distributors", + "permissions": { + "config": "write", + "dashboard": "write", + "integrations": "write", + "missions": "write", + "users": "write" + } + }, + { + "allow_pin": false, + "id": "group_administrators", + "name": "Administrators", + "permissions": { + "config": "write", + "dashboard": "write", + "integrations": "write", + "missions": "write", + "users": "write" + } + }, + { + "allow_pin": true, + "id": "group_users", + "name": "Users", + "permissions": { + "config": "read", + "dashboard": "write", + "integrations": "read", + "missions": "read", + "users": "none" + } + } + ], + "users": [ + { + "display_name": "Distributor", + "enabled": true, + "group_id": "group_distributors", + "id": "user_distributor", + "password_hash": "e245409d2efb801adfb55abc4f8298deff27e86d9c3ca11a05e1403de3d4cc44", + "password_salt": "9c23467cf7b338b6cd27dab6f411135a", + "pin_hash": null, + "pin_salt": null, + "username": "Distributor" + }, + { + "display_name": "Administrator", + "enabled": true, + "group_id": "group_administrators", + "id": "user_admin", + "password_hash": "d07eb95a7364e6fb9fe2ce152e3617dc0f23bb943263c5ca2f77a4cbbf5d5396", + "password_salt": "804fec3b7b4910d6bdde1fb3782371e5", + "pin_hash": null, + "pin_salt": null, + "username": "Admin" + }, + { + "display_name": "Operator", + "enabled": true, + "group_id": "group_users", + "id": "user_operator", + "password_hash": "b9091e9f6bcbd060231cc2f2e0ae028af88db0bca2af068548cb7604329fbdc9", + "password_salt": "d2eedd0b0d2446af5ba875ebcff658f1", + "pin_hash": "8dd8a6d52c7b7b76fde819aae2d5d3e3e06b321f71f61ef2918be879ace49d71", + "pin_salt": "8d0ec0ed4339dafcb0f099a4c77895a2", + "username": "User" + } + ], + "version": 1 +} \ No newline at end of file diff --git a/docs/Reference_guide.md b/docs/Reference_guide.md index e60faaf..c0c154e 100644 --- a/docs/Reference_guide.md +++ b/docs/Reference_guide.md @@ -30,7 +30,9 @@ Giao diện web trên robot: **responsive** (PC, tablet, portrait/landscape). Tr ### 2.1 Signing in -#### Luồng truy cập +> **Test3:** tính năng đã triển khai — xem [Test3 — Signing in](#test3--signing-in-đã-triển-khai). + +#### Luồng truy cập (MiR) ``` Thiết bị → kết nối mạng robot → trình duyệt → trang Sign in → shell app (Dashboard / Setup / …) @@ -87,51 +89,143 @@ Admin có thể tạo thêm user group (ví dụ `Operators`) và gán quyền t - Không nên nhiều người dùng chung một account. - SW mới (~2023): **auto sign-out** theo user group; MiR Fleet hỗ trợ **OAuth 2.0 / OpenID Connect**. -#### Thiết kế web tương tự MiR (gợi ý Test3) +#### Test3 — Signing in (đã triển khai) -**UI trang Sign in** +Tính năng đăng nhập theo MiR §2.1 đã tích hợp vào `lidar_manager_web`. Toàn bộ API (trừ health/login/logout) yêu cầu session; UI bị chặn cho đến khi đăng nhập thành công. + +##### Luồng người dùng + +``` +Trình duyệt → / (trang Sign in) + → POST /api/auth/login (password hoặc PIN) + → Cookie lm_session + shell app (Dashboard / Cấu hình / Missions / Tích hợp) + → Menu user (góc phải): đổi mật khẩu, đăng xuất +``` + +- Static (`www/`) phục vụ công khai để tải trang login. +- `auth.js` gọi `GET /api/auth/me` khi mở trang; session hợp lệ thì vào app ngay. +- `app.js`, `missions.js`, `dashboard.js`, `integrations.js` chỉ khởi động sau sự kiện `lm:auth-ready`. +- API mission queue và các endpoint khác **không** được gọi trước khi đăng nhập. + +##### Giao diện web (MiR-style) | Thành phần | Mô tả | |------------|--------| -| Layout | Full-screen, logo, form giữa màn hình | -| Tabs | `Username & password` \| `PIN code` | -| Nút | **Sign in** (MiR dùng thuật ngữ này) | -| Responsive | Input/nút lớn, dễ chạm trên tablet sàn | +| Nền | Xanh `#3d6cb3`, full-screen | +| Header | Tên robot (`RobotApp`) + «Chọn cách đăng nhập» + 2 tab | +| Tab **Tên đăng nhập và mật khẩu** | 2 cột: hướng dẫn trái, form phải; nút xanh «Đăng nhập» | +| Tab **Mã PIN** | Trái: hướng dẫn + 4 ô vuông (•); phải: keypad 1–9, 0, ✕ | +| PIN | Tự đăng nhập khi đủ 4 số; hỗ trợ bàn phím vật lý | +| Sau login | Menu user topbar; ẩn/vô hiệu menu theo quyền read-only | -**Kiến trúc** +File: `www/index.html`, `www/auth.js`, `www/style.css`. -``` -Sign-in (public) → POST /api/auth/login → session/JWT - → Shell app + route guard theo permissions - → API middleware (read/write từng module) +##### Tài khoản mặc định + +Tự tạo lần đầu trong `data/auth.json` (cùng thư mục `state.json`): + +| Username | Password | User group | Ghi chú | +|----------|----------|------------|---------| +| `Admin` | `admin` | Administrators | Full quyền | +| `User` | `user` | Users | Dashboard write; phần còn lại read | +| `Distributor` | `distributor` | Distributors | Full quyền | + +- Username đăng nhập **không phân biệt hoa thường** (`admin` = `Admin`). +- **PIN:** không có mã mặc định (giống MiR). Chỉ nhóm **Users** (`allow_pin: true`); admin gán qua API. + +##### User groups và permissions + +Credentials → **user**; quyền → **group**. Module: `dashboard`, `config`, `missions`, `integrations`, `users` — giá trị `none` | `read` | `write`. + +| Group | PIN | dashboard | config | missions | integrations | users | +|-------|-----|-----------|--------|----------|--------------|-------| +| Distributors | Không | write | write | write | write | write | +| Administrators | Không | write | write | write | write | write | +| Users | Sau khi gán | write | read | read | read | none | + +| Group | Menu UI | +|-------|---------| +| Users | Dashboard + xem Cấu hình/Missions/Tích hợp (nút ghi read-only) | +| Administrators / Distributors | Toàn bộ menu; quản lý user qua API | + +##### Session và middleware + +| Cơ chế | Chi tiết | +|--------|----------| +| Session | Server-side; mất khi restart process | +| Cookie | `lm_session=; HttpOnly; SameSite=Lax` | +| Header | `Authorization: Bearer ` | +| Middleware | `AuthService::preRoute` trên `/api/*` | +| Public | `GET /api/health`, `POST /api/auth/login`, `POST /api/auth/logout`, `OPTIONS` | +| Dev | `LM_AUTH_DISABLED=1` tắt auth | + +**API → module** (kiểm tra read/write): + +| Module | Prefix | +|--------|--------| +| config | `/api/lidars`, `/api/imus`, `/api/layouts`, `/api/state`, … | +| missions | `/api/missions`, `/api/mission_queue` | +| integrations | `/api/triggers`, `/api/schedules`, `/api/fleet`, `/api/modbus`, `/api/v2.0.0/` | +| users | `/api/users`, `/api/user_groups` | + +##### REST API + +| Method | Endpoint | Auth | Mô tả | +|--------|----------|------|--------| +| POST | `/api/auth/login` | Public | `{ username, password }` hoặc `{ pin }` | +| POST | `/api/auth/logout` | Public | Xóa session + cookie | +| GET | `/api/auth/me` | Session | User, group, permissions | +| PUT | `/api/auth/password` | Session | Đổi mật khẩu | +| GET | `/api/user_groups` | users read | Danh sách nhóm | +| GET | `/api/users` | users read | Danh sách user | +| POST | `/api/users` | users write | Tạo user | +| PUT | `/api/users/:id` | users write | Sửa user / gán PIN (`pin: null` = xóa) | +| DELETE | `/api/users/:id` | users write | Xóa user | + +**Ví dụ login + gán PIN** + +```bash +curl -c c.txt -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"Admin","password":"admin"}' + +curl -b c.txt -X PUT http://localhost:8080/api/users/user_operator \ + -H 'Content-Type: application/json' \ + -d '{"pin":"1234"}' ``` -**API gợi ý** +##### Lưu trữ và mã nguồn -| Method | Endpoint | Mô tả | -|--------|----------|--------| -| POST | `/api/auth/login` | `{ username, password }` hoặc `{ pin }` | -| POST | `/api/auth/logout` | Hủy session | -| GET | `/api/auth/me` | User, group, permissions (ẩn menu) | -| PUT | `/api/auth/password` | User tự đổi password | -| CRUD | `/api/users`, `/api/user_groups` | Cần quyền admin | +| Thành phần | Vị trí | +|------------|--------| +| Dữ liệu | `data/auth.json` — groups, users (hash + salt) | +| Backend | `src/auth/auth_service.cpp`, `src/util/crypto_util.cpp`, `src/app/lidar_manager_app.cpp` | +| Frontend | `www/auth.js`, `www/index.html`, `www/style.css` | +| Test | `scripts/test/smoke.sh`, `tests/test_api_integration.py` | -**Phân quyền UI sau login (ví dụ)** +Hash: SHA-256 + salt (`sha256(salt:password)` / `sha256(salt:pin:pin)`). -| Group | Truy cập | -|-------|----------| -| User / Operator | Dashboard (mission, queue, pause/cancel) | -| Administrator | + Setup (Missions, Maps, Tích hợp, Cấu hình) | -| Distributor | + Users / User groups | +##### Kiểm thử và vận hành -#### Test3 hiện tại +```bash +./scripts/lm.sh test run # smoke tự login Admin; pytest test_auth_* +``` + +- Docker: `www/` copy lúc build → `docker compose up --build -d` sau sửa UI. +- Hard refresh (`Ctrl+Shift+R`) nếu cache JS/CSS. + +##### So sánh MiR ↔ Test3 | MiR §2.1 | Test3 | |----------|-------| -| Sign in bắt buộc | Chưa có — mở thẳng UI | -| User / User group | Chưa có | -| PIN operator | Chưa có | -| REST auth | API công khai (phù hợp dev) | +| Sign in bắt buộc | Có | +| Tab password \| PIN + keypad | Có | +| 3 role mặc định | Admin / User / Distributor | +| PIN không mặc định | Có — admin gán API | +| User menu, đổi password, sign out | Có | +| Credentials / permissions tách biệt | Có | +| Setup → Users (UI) | Chưa — chỉ API | +| Auto sign-out / OAuth Fleet | Chưa | ### 2.2 Navigating the MiR robot interface @@ -341,7 +435,7 @@ Xác thực: HTTP Basic (user/password robot). | Loop / Break / Continue | `www/missions.js` + `mission_queue.cpp` | | Pause / Continue | `/api/mission_queue/pause`, `/continue` | | Cancel (Modbus coil 3) | `/api/mission_queue/cancel` | -| Sign in / User groups | **Chưa triển khai** (xem §2.1) | +| Sign in / User groups | **Đã triển khai** — §2.1 (`AuthService`, UI MiR, `data/auth.json`) | --- diff --git a/scripts/test/smoke.sh b/scripts/test/smoke.sh index aeb20dd..e4ecaaf 100755 --- a/scripts/test/smoke.sh +++ b/scripts/test/smoke.sh @@ -30,7 +30,7 @@ PY } http_code() { - curl -s -o "$1" -w '%{http_code}' "${@:2}" + curl -s -o "$1" -w '%{http_code}' "${CURL_OPTS[@]}" "${@:2}" } assert_code() { @@ -67,15 +67,37 @@ PY TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +COOKIE_JAR="$TMP/cookies.txt" +CURL_OPTS=() echo "API smoke tests → $BASE" echo -# --- Health & static --- +# --- Health & static (no auth) --- assert_code "GET /api/health" 200 "$TMP/health.json" -X GET "$BASE/api/health" assert_json_true "health ok" "$TMP/health.json" 'doc.get("ok") is True' +assert_code "GET /api/state without auth" 401 "$TMP/state_unauth.json" -X GET "$BASE/api/state" + +assert_code "POST /api/auth/login bad password" 401 "$TMP/login_bad.json" \ + -X POST "$BASE/api/auth/login" \ + -H 'Content-Type: application/json' \ + -d '{"username":"Admin","password":"wrong"}' + +assert_code "POST /api/auth/login" 200 "$TMP/login.json" \ + -X POST "$BASE/api/auth/login" \ + -H 'Content-Type: application/json' \ + -c "$COOKIE_JAR" \ + -d '{"username":"Admin","password":"admin"}' +assert_json_true "login user" "$TMP/login.json" 'doc.get("user",{}).get("username") == "Admin"' + +CURL_OPTS=(-b "$COOKIE_JAR" -c "$COOKIE_JAR") + +assert_code "GET /api/auth/me" 200 "$TMP/me.json" -X GET "$BASE/api/auth/me" +assert_json_true "auth me" "$TMP/me.json" 'doc.get("user",{}).get("group_name") == "Administrators"' + assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/" +assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js" assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js" assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state" @@ -107,12 +129,12 @@ echo assert_json_true "missions available" "$TMP/missions.json" 'len(doc.get("missions", [])) >= 1' # --- Queue pause/continue (chạy sớm, trước các test enqueue khác) --- -curl -s -X DELETE "$BASE/api/mission_queue" -o /dev/null || true -curl -s -X POST "$BASE/api/mission_queue" \ +curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/mission_queue" -o /dev/null || true +curl -s "${CURL_OPTS[@]}" -X POST "$BASE/api/mission_queue" \ -H 'Content-Type: application/json' \ -d "{\"mission_id\":\"$MISSION_ID\"}" -o "$TMP/qpost.json" for _ in $(seq 1 30); do - curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" + curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")" if [[ "$RUNNER_STATE" == "running" ]]; then break @@ -127,7 +149,7 @@ if [[ "$RUNNER_STATE" == "running" ]]; then -X POST "$BASE/api/mission_queue/continue" assert_json_true "runner not paused" "$TMP/cont.json" 'doc.get("state") != "paused"' for _ in $(seq 1 15); do - curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" + curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")" if [[ "$RUNNER_STATE" == "running" || "$RUNNER_STATE" == "paused" ]]; then break @@ -138,7 +160,7 @@ if [[ "$RUNNER_STATE" == "running" ]]; then assert_code "POST /api/mission_queue/cancel" 200 "$TMP/cancel.json" \ -X POST "$BASE/api/mission_queue/cancel" for _ in $(seq 1 40); do - curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" + curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")" if [[ "$RUNNER_STATE" == "idle" ]]; then break @@ -191,8 +213,8 @@ else fi # --- Clear queue --- -curl -s -X DELETE "$BASE/api/mission_queue" -o /dev/null || true -curl -s -X DELETE "$BASE/api/v2.0.0/mission_queue" -o /dev/null || true +curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/mission_queue" -o /dev/null || true +curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/v2.0.0/mission_queue" -o /dev/null || true # --- MiR v2 enqueue (Cách C REST) --- assert_code "POST /api/v2.0.0/mission_queue" 201 "$TMP/v2q.json" \ @@ -204,7 +226,7 @@ assert_json_true "v2 queue entry mission_id" "$TMP/v2q.json" \ assert_code "GET /api/v2.0.0/mission_queue" 200 "$TMP/v2list.json" -X GET "$BASE/api/v2.0.0/mission_queue" for _ in $(seq 1 15); do - curl -s "$BASE/api/v2.0.0/mission_queue" -o "$TMP/v2list.json" + curl -s "${CURL_OPTS[@]}" "$BASE/api/v2.0.0/mission_queue" -o "$TMP/v2list.json" if python3 -c "import json; d=json.load(open('$TMP/v2list.json')); exit(0 if isinstance(d,list) and len(d)>=1 else 1)" 2>/dev/null; then break fi diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index 84138b0..d0e7243 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -1,5 +1,6 @@ #include "app/lidar_manager_app.hpp" +#include "auth/auth_service.hpp" #include "mission/mission_enqueue.hpp" #include "mission/mission_queue.hpp" #include "mission/mission_scheduler.hpp" @@ -41,9 +42,16 @@ int LidarManagerApp::run() ModbusTriggerService modbus(mission_store, enqueue_fn, 5502); MissionScheduler scheduler(mission_store, enqueue_fn); + AuthService auth(data_path_.parent_path() / "auth.json"); + httplib::Server svr; + svr.set_pre_routing_handler([&auth](const httplib::Request& req, httplib::Response& res) { + return auth.preRoute(req, res); + }); + ApiServer api(repo, mission_queue, mission_store, modbus, scheduler); api.registerRoutes(svr); + auth.registerRoutes(svr); StaticFileServer::mount(svr, www_root_); std::fprintf(stderr, diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp new file mode 100644 index 0000000..7754163 --- /dev/null +++ b/src/auth/auth_service.cpp @@ -0,0 +1,776 @@ +#include "auth/auth_service.hpp" + +#include "util/crypto_util.hpp" +#include "util/file_util.hpp" +#include "util/http_util.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +#include +#include +#include + +namespace lm { + +thread_local const AuthSession* AuthService::tls_session_ = nullptr; + +namespace { + +constexpr const char* kSessionCookie = "lm_session"; + +nlohmann::json defaultPermissionsAllWrite() +{ + return {{"dashboard", "write"}, + {"config", "write"}, + {"missions", "write"}, + {"integrations", "write"}, + {"users", "write"}}; +} + +nlohmann::json defaultPermissionsUserGroup() +{ + return {{"dashboard", "write"}, + {"config", "read"}, + {"missions", "read"}, + {"integrations", "read"}, + {"users", "none"}}; +} + +nlohmann::json makeUser(const std::string& id, + const std::string& username, + const std::string& password, + const std::string& group_id, + const std::string& display_name) +{ + const std::string salt = CryptoUtil::randomToken(16); + return {{"id", id}, + {"username", username}, + {"display_name", display_name}, + {"group_id", group_id}, + {"password_salt", salt}, + {"password_hash", CryptoUtil::hashPassword(salt, password)}, + {"pin_salt", nullptr}, + {"pin_hash", nullptr}, + {"enabled", true}}; +} + +} // namespace + +AuthService::AuthService(std::filesystem::path store_path) : store_path_(std::move(store_path)) +{ + loadOrSeed(); +} + +void AuthService::loadOrSeed() +{ + std::lock_guard lock(mu_); + data_ = nlohmann::json::object(); + if (std::filesystem::exists(store_path_)) + { + try + { + data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_)); + } + catch (...) + { + data_ = nlohmann::json::object(); + } + } + + if (!data_.contains("version")) + data_["version"] = 1; + if (!data_.contains("groups") || !data_["groups"].is_array() || data_["groups"].empty()) + { + data_["groups"] = nlohmann::json::array({ + {{"id", "group_distributors"}, + {"name", "Distributors"}, + {"allow_pin", false}, + {"permissions", defaultPermissionsAllWrite()}}, + {{"id", "group_administrators"}, + {"name", "Administrators"}, + {"allow_pin", false}, + {"permissions", defaultPermissionsAllWrite()}}, + {{"id", "group_users"}, + {"name", "Users"}, + {"allow_pin", true}, + {"permissions", defaultPermissionsUserGroup()}}, + }); + } + if (!data_.contains("users") || !data_["users"].is_array() || data_["users"].empty()) + { + data_["users"] = nlohmann::json::array({ + makeUser("user_distributor", "Distributor", "distributor", "group_distributors", "Distributor"), + makeUser("user_admin", "Admin", "admin", "group_administrators", "Administrator"), + makeUser("user_operator", "User", "user", "group_users", "Operator"), + }); + } + saveUnlocked(); +} + +void AuthService::saveUnlocked() +{ + const auto parent = store_path_.parent_path(); + if (!parent.empty()) + std::filesystem::create_directories(parent); + FileUtil::writeBinaryAtomic(store_path_, data_.dump(2)); +} + +const AuthSession* AuthService::currentSession() const +{ + return tls_session_; +} + +std::string AuthService::extractToken(const httplib::Request& req) const +{ + if (req.has_header("Cookie")) + { + const std::string cookie = req.get_header_value("Cookie"); + const std::string prefix = std::string(kSessionCookie) + "="; + const auto pos = cookie.find(prefix); + if (pos != std::string::npos) + { + const auto start = pos + prefix.size(); + const auto end = cookie.find(';', start); + return cookie.substr(start, end == std::string::npos ? std::string::npos : end - start); + } + } + if (req.has_header("Authorization")) + { + const std::string auth = req.get_header_value("Authorization"); + constexpr const char* kBearer = "Bearer "; + if (auth.rfind(kBearer, 0) == 0) + return auth.substr(std::strlen(kBearer)); + } + return {}; +} + +bool AuthService::isPublicApiPath(const std::string& path, const std::string& method) +{ + if (method == "OPTIONS") + return true; + return path == "/api/health" || path == "/api/auth/login" || path == "/api/auth/logout"; +} + +std::optional AuthService::resourceForApiPath(const std::string& path) +{ + if (path.rfind("/api/auth/", 0) == 0) + return std::nullopt; + if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0) + return "users"; + if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0) + return "missions"; + if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 || + path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 || + path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0) + return "integrations"; + if (path.rfind("/api/", 0) == 0) + return "config"; + return std::nullopt; +} + +bool AuthService::requiresWrite(const std::string& method) +{ + return method != "GET" && method != "HEAD" && method != "OPTIONS"; +} + +bool AuthService::permissionAllows(const nlohmann::json& perms, + const std::string& resource, + bool write) const +{ + if (!perms.is_object() || !perms.contains(resource)) + return false; + const std::string level = perms.value(resource, "none"); + if (level == "none") + return false; + if (write) + return level == "write"; + return level == "read" || level == "write"; +} + +const nlohmann::json* AuthService::findUserByIdUnlocked(const std::string& id) const +{ + if (!data_.contains("users") || !data_["users"].is_array()) + return nullptr; + for (const auto& u : data_["users"]) + { + if (u.value("id", "") == id) + return &u; + } + return nullptr; +} + +const nlohmann::json* AuthService::findUserByUsernameUnlocked(const std::string& username) const +{ + if (!data_.contains("users") || !data_["users"].is_array()) + return nullptr; + const std::string needle = StringUtil::toLower(StringUtil::trimCopy(username)); + for (const auto& u : data_["users"]) + { + if (StringUtil::toLower(u.value("username", "")) == needle) + return &u; + } + return nullptr; +} + +const nlohmann::json* AuthService::findGroupByIdUnlocked(const std::string& id) const +{ + if (!data_.contains("groups") || !data_["groups"].is_array()) + return nullptr; + for (const auto& g : data_["groups"]) + { + if (g.value("id", "") == id) + return &g; + } + return nullptr; +} + +bool AuthService::verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const +{ + const std::string salt = user.value("password_salt", ""); + const std::string hash = user.value("password_hash", ""); + if (salt.empty() || hash.empty()) + return false; + return CryptoUtil::hashPassword(salt, password) == hash; +} + +bool AuthService::verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const +{ + if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) + return false; + if (user.value("pin_hash", nlohmann::json()).is_null()) + return false; + const std::string salt = user.value("pin_salt", ""); + const std::string hash = user.value("pin_hash", ""); + if (salt.empty() || hash.empty()) + return false; + return CryptoUtil::hashPin(salt, pin) == hash; +} + +bool AuthService::groupAllowsPinUnlocked(const std::string& group_id) const +{ + const auto* group = findGroupByIdUnlocked(group_id); + return group && group->value("allow_pin", false); +} + +std::optional AuthService::buildSessionUnlocked(const nlohmann::json& user) +{ + const auto* group = findGroupByIdUnlocked(user.value("group_id", "")); + if (!group) + return std::nullopt; + + AuthSession session; + session.token = CryptoUtil::randomToken(32); + session.user_id = user.value("id", ""); + session.username = user.value("username", ""); + session.group_id = group->value("id", ""); + session.group_name = group->value("name", ""); + session.permissions = group->value("permissions", nlohmann::json::object()); + return session; +} + +nlohmann::json AuthService::userPublicView(const nlohmann::json& user, const nlohmann::json& group) +{ + return {{"id", user.value("id", "")}, + {"username", user.value("username", "")}, + {"display_name", user.value("display_name", "")}, + {"group_id", user.value("group_id", "")}, + {"group_name", group.value("name", "")}, + {"permissions", group.value("permissions", nlohmann::json::object())}, + {"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}}; +} + +std::optional AuthService::loginPassword(const std::string& username, + const std::string& password, + std::string& err) +{ + std::lock_guard lock(mu_); + const auto* user = findUserByUsernameUnlocked(username); + if (!user || !user->value("enabled", true)) + { + err = "invalid credentials"; + return std::nullopt; + } + if (!verifyPasswordUnlocked(*user, password)) + { + err = "invalid credentials"; + return std::nullopt; + } + auto session = buildSessionUnlocked(*user); + if (!session) + { + err = "invalid user group"; + return std::nullopt; + } + sessions_[session->token] = *session; + const auto* group = findGroupByIdUnlocked(user->value("group_id", "")); + return nlohmann::json{{"token", session->token}, + {"user", userPublicView(*user, group ? *group : nlohmann::json::object())}}; +} + +std::optional AuthService::loginPin(const std::string& pin, std::string& err) +{ + std::lock_guard lock(mu_); + if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) + { + err = "pin must be 4 digits"; + return std::nullopt; + } + + const nlohmann::json* matched = nullptr; + for (const auto& user : data_["users"]) + { + if (!user.value("enabled", true)) + continue; + if (!groupAllowsPinUnlocked(user.value("group_id", ""))) + continue; + if (verifyPinUnlocked(user, pin)) + { + matched = &user; + break; + } + } + + if (!matched) + { + err = "invalid pin"; + return std::nullopt; + } + + auto session = buildSessionUnlocked(*matched); + if (!session) + { + err = "invalid user group"; + return std::nullopt; + } + sessions_[session->token] = *session; + const auto* group = findGroupByIdUnlocked(matched->value("group_id", "")); + return nlohmann::json{{"token", session->token}, + {"user", userPublicView(*matched, group ? *group : nlohmann::json::object())}}; +} + +bool AuthService::logout(const std::string& token) +{ + if (token.empty()) + return false; + std::lock_guard lock(mu_); + return sessions_.erase(token) > 0; +} + +std::optional AuthService::sessionInfo(const std::string& token) const +{ + if (token.empty()) + return std::nullopt; + std::lock_guard lock(mu_); + const auto it = sessions_.find(token); + if (it == sessions_.end()) + return std::nullopt; + const auto* user = findUserByIdUnlocked(it->second.user_id); + if (!user) + return std::nullopt; + const auto* group = findGroupByIdUnlocked(user->value("group_id", "")); + return userPublicView(*user, group ? *group : nlohmann::json::object()); +} + +bool AuthService::changePassword(const std::string& token, + const std::string& current_password, + const std::string& new_password, + std::string& err) +{ + if (new_password.size() < 4) + { + err = "password too short"; + return false; + } + + std::lock_guard lock(mu_); + const auto it = sessions_.find(token); + if (it == sessions_.end()) + { + err = "not authenticated"; + return false; + } + + for (auto& user : data_["users"]) + { + if (user.value("id", "") != it->second.user_id) + continue; + if (!verifyPasswordUnlocked(user, current_password)) + { + err = "current password incorrect"; + return false; + } + const std::string salt = CryptoUtil::randomToken(16); + user["password_salt"] = salt; + user["password_hash"] = CryptoUtil::hashPassword(salt, new_password); + saveUnlocked(); + return true; + } + + err = "user not found"; + return false; +} + +nlohmann::json AuthService::listGroups() const +{ + std::lock_guard lock(mu_); + nlohmann::json out = nlohmann::json::array(); + for (const auto& g : data_["groups"]) + { + out.push_back({{"id", g.value("id", "")}, + {"name", g.value("name", "")}, + {"allow_pin", g.value("allow_pin", false)}, + {"permissions", g.value("permissions", nlohmann::json::object())}}); + } + return out; +} + +nlohmann::json AuthService::listUsers() const +{ + std::lock_guard lock(mu_); + nlohmann::json out = nlohmann::json::array(); + for (const auto& u : data_["users"]) + { + const auto* group = findGroupByIdUnlocked(u.value("group_id", "")); + out.push_back(userPublicView(u, group ? *group : nlohmann::json::object())); + } + return out; +} + +std::optional AuthService::createUser(const nlohmann::json& payload, std::string& err) +{ + const std::string username = payload.value("username", ""); + const std::string password = payload.value("password", ""); + const std::string group_id = payload.value("group_id", ""); + if (username.empty() || password.empty() || group_id.empty()) + { + err = "username, password and group_id required"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + if (findUserByUsernameUnlocked(username)) + { + err = "username already exists"; + return std::nullopt; + } + if (!findGroupByIdUnlocked(group_id)) + { + err = "unknown group"; + return std::nullopt; + } + + const std::string id = "user_" + IdUtil::newId(); + auto user = makeUser(id, username, password, group_id, payload.value("display_name", username)); + if (payload.contains("pin") && !payload["pin"].is_null()) + { + const std::string pin = payload.value("pin", ""); + if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) + { + err = "pin must be 4 digits"; + return std::nullopt; + } + if (!groupAllowsPinUnlocked(group_id)) + { + err = "group does not allow pin"; + return std::nullopt; + } + const std::string pin_salt = CryptoUtil::randomToken(16); + user["pin_salt"] = pin_salt; + user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin); + } + + data_["users"].push_back(user); + saveUnlocked(); + const auto* group = findGroupByIdUnlocked(group_id); + return userPublicView(user, group ? *group : nlohmann::json::object()); +} + +std::optional AuthService::updateUser(const std::string& id, + const nlohmann::json& payload, + std::string& err) +{ + std::lock_guard lock(mu_); + for (auto& user : data_["users"]) + { + if (user.value("id", "") != id) + continue; + + if (payload.contains("display_name")) + user["display_name"] = payload["display_name"]; + if (payload.contains("enabled")) + user["enabled"] = payload["enabled"]; + if (payload.contains("group_id")) + { + const std::string group_id = payload.value("group_id", ""); + if (!findGroupByIdUnlocked(group_id)) + { + err = "unknown group"; + return std::nullopt; + } + user["group_id"] = group_id; + } + if (payload.contains("password") && payload["password"].is_string()) + { + const std::string password = payload["password"]; + const std::string salt = CryptoUtil::randomToken(16); + user["password_salt"] = salt; + user["password_hash"] = CryptoUtil::hashPassword(salt, password); + } + if (payload.contains("pin")) + { + if (payload["pin"].is_null()) + { + user["pin_salt"] = nullptr; + user["pin_hash"] = nullptr; + } + else + { + const std::string pin = payload.value("pin", ""); + if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) + { + err = "pin must be 4 digits"; + return std::nullopt; + } + if (!groupAllowsPinUnlocked(user.value("group_id", ""))) + { + err = "group does not allow pin"; + return std::nullopt; + } + const std::string pin_salt = CryptoUtil::randomToken(16); + user["pin_salt"] = pin_salt; + user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin); + } + } + + saveUnlocked(); + const auto* group = findGroupByIdUnlocked(user.value("group_id", "")); + return userPublicView(user, group ? *group : nlohmann::json::object()); + } + + err = "user not found"; + return std::nullopt; +} + +bool AuthService::deleteUser(const std::string& id, std::string& err) +{ + std::lock_guard lock(mu_); + auto& users = data_["users"]; + const auto it = std::remove_if(users.begin(), users.end(), [&](const nlohmann::json& u) { + return u.value("id", "") == id; + }); + if (it == users.end()) + { + err = "user not found"; + return false; + } + users.erase(it, users.end()); + saveUnlocked(); + return true; +} + +bool AuthService::authorizeApiRequest(const httplib::Request& req, httplib::Response& res) +{ + if (const char* disabled = std::getenv("LM_AUTH_DISABLED"); disabled && std::string(disabled) == "1") + return true; + + const std::string token = extractToken(req); + if (token.empty()) + { + HttpUtil::jsonError(res, 401, "authentication required"); + return false; + } + + std::lock_guard lock(mu_); + const auto it = sessions_.find(token); + if (it == sessions_.end()) + { + HttpUtil::jsonError(res, 401, "invalid or expired session"); + return false; + } + + tls_session_ = &it->second; + + const auto resource = resourceForApiPath(req.path); + if (!resource) + return true; + + const bool write = requiresWrite(req.method); + if (!permissionAllows(it->second.permissions, *resource, write)) + { + HttpUtil::jsonError(res, 403, "insufficient permissions"); + return false; + } + return true; +} + +httplib::Server::HandlerResponse AuthService::preRoute(const httplib::Request& req, + httplib::Response& res) +{ + tls_session_ = nullptr; + + if (req.path.rfind("/api/", 0) != 0) + return httplib::Server::HandlerResponse::Unhandled; + + if (isPublicApiPath(req.path, req.method)) + return httplib::Server::HandlerResponse::Unhandled; + + if (!authorizeApiRequest(req, res)) + { + HttpUtil::addCors(res); + return httplib::Server::HandlerResponse::Handled; + } + + return httplib::Server::HandlerResponse::Unhandled; +} + +void AuthService::registerRoutes(httplib::Server& svr) +{ + svr.Post("/api/auth/login", [this](const httplib::Request& req, httplib::Response& res) { + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + HttpUtil::jsonError(res, 400, "invalid json"); + return; + } + + std::string err; + std::optional result; + if (body.contains("pin")) + result = loginPin(body.value("pin", ""), err); + else + result = loginPassword(body.value("username", ""), body.value("password", ""), err); + + if (!result) + { + HttpUtil::jsonError(res, 401, err); + return; + } + + const std::string token = result->value("token", ""); + res.set_header("Set-Cookie", + std::string(kSessionCookie) + "=" + token + "; Path=/; HttpOnly; SameSite=Lax"); + res.set_content(result->dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Post("/api/auth/logout", [this](const httplib::Request& req, httplib::Response& res) { + const std::string token = extractToken(req); + logout(token); + res.set_header("Set-Cookie", std::string(kSessionCookie) + "=; Path=/; HttpOnly; Max-Age=0"); + res.set_content(R"({"ok":true})", "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Get("/api/auth/me", [this](const httplib::Request& req, httplib::Response& res) { + const std::string token = extractToken(req); + const auto info = sessionInfo(token); + if (!info) + { + HttpUtil::jsonError(res, 401, "not authenticated"); + return; + } + nlohmann::json out = {{"user", *info}}; + res.set_content(out.dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Put("/api/auth/password", [this](const httplib::Request& req, httplib::Response& res) { + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + HttpUtil::jsonError(res, 400, "invalid json"); + return; + } + + const std::string token = extractToken(req); + std::string err; + if (!changePassword(token, + body.value("current_password", ""), + body.value("new_password", ""), + err)) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.set_content(R"({"ok":true})", "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) { + nlohmann::json out = {{"groups", listGroups()}}; + res.set_content(out.dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Get("/api/users", [this](const httplib::Request&, httplib::Response& res) { + nlohmann::json out = {{"users", listUsers()}}; + res.set_content(out.dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Post("/api/users", [this](const httplib::Request& req, httplib::Response& res) { + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + HttpUtil::jsonError(res, 400, "invalid json"); + return; + } + std::string err; + const auto user = createUser(body, err); + if (!user) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.status = 201; + res.set_content(user->dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Put(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + HttpUtil::jsonError(res, 400, "invalid json"); + return; + } + std::string err; + const auto user = updateUser(req.matches[1].str(), body, err); + if (!user) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.set_content(user->dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Delete(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + std::string err; + if (!deleteUser(req.matches[1].str(), err)) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.set_content(R"({"ok":true})", "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); +} + +} // namespace lm diff --git a/src/auth/auth_service.hpp b/src/auth/auth_service.hpp new file mode 100644 index 0000000..4fbd30c --- /dev/null +++ b/src/auth/auth_service.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace lm { + +struct AuthSession +{ + std::string token; + std::string user_id; + std::string username; + std::string group_id; + std::string group_name; + nlohmann::json permissions; +}; + +class AuthService +{ +public: + explicit AuthService(std::filesystem::path store_path); + + httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res); + + const AuthSession* currentSession() const; + + std::optional loginPassword(const std::string& username, + const std::string& password, + std::string& err); + std::optional loginPin(const std::string& pin, std::string& err); + bool logout(const std::string& token); + std::optional sessionInfo(const std::string& token) const; + bool changePassword(const std::string& token, + const std::string& current_password, + const std::string& new_password, + std::string& err); + + nlohmann::json listGroups() const; + nlohmann::json listUsers() const; + std::optional createUser(const nlohmann::json& payload, std::string& err); + std::optional updateUser(const std::string& id, + const nlohmann::json& payload, + std::string& err); + bool deleteUser(const std::string& id, std::string& err); + + void registerRoutes(httplib::Server& svr); + +private: + std::filesystem::path store_path_; + mutable std::mutex mu_; + nlohmann::json data_; + std::unordered_map sessions_; + thread_local static const AuthSession* tls_session_; + + void loadOrSeed(); + void saveUnlocked(); + std::string extractToken(const httplib::Request& req) const; + std::optional buildSessionUnlocked(const nlohmann::json& user); + bool permissionAllows(const nlohmann::json& perms, const std::string& resource, bool write) const; + bool authorizeApiRequest(const httplib::Request& req, httplib::Response& res); + static bool isPublicApiPath(const std::string& path, const std::string& method); + static std::optional resourceForApiPath(const std::string& path); + static bool requiresWrite(const std::string& method); + static nlohmann::json userPublicView(const nlohmann::json& user, const nlohmann::json& group); + const nlohmann::json* findUserByIdUnlocked(const std::string& id) const; + const nlohmann::json* findUserByUsernameUnlocked(const std::string& username) const; + const nlohmann::json* findGroupByIdUnlocked(const std::string& id) const; + bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const; + bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const; + bool groupAllowsPinUnlocked(const std::string& group_id) const; +}; + +} // namespace lm diff --git a/src/util/crypto_util.cpp b/src/util/crypto_util.cpp new file mode 100644 index 0000000..4e5b943 --- /dev/null +++ b/src/util/crypto_util.cpp @@ -0,0 +1,143 @@ +#include "util/crypto_util.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lm { +namespace { + +constexpr std::uint32_t rotr(std::uint32_t x, std::uint32_t n) +{ + return (x >> n) | (x << (32 - n)); +} + +void sha256Transform(std::array& state, const std::uint8_t block[64]) +{ + static const std::uint32_t k[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; + + std::uint32_t w[64]; + for (int i = 0; i < 16; ++i) + { + w[i] = (static_cast(block[i * 4]) << 24) | + (static_cast(block[i * 4 + 1]) << 16) | + (static_cast(block[i * 4 + 2]) << 8) | + static_cast(block[i * 4 + 3]); + } + for (int i = 16; i < 64; ++i) + { + const std::uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const std::uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + std::uint32_t a = state[0]; + std::uint32_t b = state[1]; + std::uint32_t c = state[2]; + std::uint32_t d = state[3]; + std::uint32_t e = state[4]; + std::uint32_t f = state[5]; + std::uint32_t g = state[6]; + std::uint32_t h = state[7]; + + for (int i = 0; i < 64; ++i) + { + const std::uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const std::uint32_t ch = (e & f) ^ ((~e) & g); + const std::uint32_t temp1 = h + S1 + ch + k[i] + w[i]; + const std::uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const std::uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const std::uint32_t temp2 = S0 + maj; + + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + } + + state[0] += a; + state[1] += b; + state[2] += c; + state[3] += d; + state[4] += e; + state[5] += f; + state[6] += g; + state[7] += h; +} + +} // namespace + +std::string CryptoUtil::sha256Hex(const std::string& data) +{ + std::array state = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + + const std::uint64_t bit_len = static_cast(data.size()) * 8; + std::vector msg(data.begin(), data.end()); + msg.push_back(0x80); + + while ((msg.size() % 64) != 56) + msg.push_back(0x00); + + for (int i = 7; i >= 0; --i) + msg.push_back(static_cast((bit_len >> (i * 8)) & 0xff)); + + for (std::size_t offset = 0; offset < msg.size(); offset += 64) + sha256Transform(state, msg.data() + offset); + + std::ostringstream oss; + for (const auto v : state) + oss << std::hex << std::setw(8) << std::setfill('0') << v; + return oss.str(); +} + +std::string CryptoUtil::randomToken(std::size_t bytes) +{ + std::array buf{}; + const std::size_t n = bytes > buf.size() ? buf.size() : bytes; + std::ifstream urandom("/dev/urandom", std::ios::binary); + if (urandom) + urandom.read(reinterpret_cast(buf.data()), static_cast(n)); + else + { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dist(0, 255); + for (std::size_t i = 0; i < n; ++i) + buf[i] = static_cast(dist(gen)); + } + + std::ostringstream oss; + for (std::size_t i = 0; i < n; ++i) + oss << std::hex << std::setw(2) << std::setfill('0') << static_cast(buf[i]); + return oss.str(); +} + +std::string CryptoUtil::hashPassword(const std::string& salt, const std::string& password) +{ + return sha256Hex(salt + ":" + password); +} + +std::string CryptoUtil::hashPin(const std::string& salt, const std::string& pin) +{ + return sha256Hex(salt + ":pin:" + pin); +} + +} // namespace lm diff --git a/src/util/crypto_util.hpp b/src/util/crypto_util.hpp new file mode 100644 index 0000000..b60ea14 --- /dev/null +++ b/src/util/crypto_util.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace lm { + +class CryptoUtil +{ +public: + static std::string sha256Hex(const std::string& data); + static std::string randomToken(std::size_t bytes = 32); + static std::string hashPassword(const std::string& salt, const std::string& password); + static std::string hashPin(const std::string& salt, const std::string& pin); +}; + +} // namespace lm diff --git a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc index 6c2283713443249bfc280a60b7abb02a299b2e49..ddc6c53554622a173c609e35d9ef03dbf2693447 100644 GIT binary patch literal 18822 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 delta 4192 zcma)9YiwLc6~42(Uf+Fqy?(`ycpb;JZ+0f2b6}`Q|$I z^~P?p)_$Hjb7tn8Z_b=E`?6qvuwqZv)PxoK{JW5xez)z7SUc-}W3x4BM5Y_`8a?)9 zrq}B6i^@wZ)##Dc>Ge|9q^o*^9=_(&8}+7(fz%p(kR&0!S#Qx>uLblreeK1nRI`*d z=e_YXq#06%vRQW?T%~+Fo+W$jVC)B{ft|~Gu6*ThC zrA?hjsN?ue-QrEua$$`q28LOecrm<{T@ybJzb#Hgb_7E-e)m1`W~7(J#Lps|6YZE# z2UttsL@YaFm#lQQpc_0PK8_^D*I}@eK=V7*mSI_DA@8WVkUju!HU_)GNYJWMAOg1V72 zY(tvMrS;orpmNDbHfLmb`fSN48ACMguF3c)wf{z6`gBe>Rg<|w#^!PAX#r`PQw0JH zj-%&6f~=naAMvA_R~v?@!C{CKy(7ZKyk@ zsh+(C#KF-1sMmUNHQvbfiJS39*vayp_$b@jL$f7oycewrz&-%1;Ew_x0~`Wu6kF?i z`(V$|N3o1$8Qkvd*;t$%(JTl@Zq-y0-6uY5Z4kG�K~iSm77|9%~WTP`S-#AUzH^ z0eBMdS@FC2vGosyS_OdRRW5t1eGmHREa4(TkhyM%QO|s zARr#(nyBq*5x-g2p+%uAx2#`m$?h8JI-#AGVP^|7#oZ@FbLVyz5yv_^=jExcr*X~4 z_hZ%pf;lfcLz=};VdNQTE%+^`t?oS3v1lqXQJhUr7WhoY9^@H_ms2AC)!E99iST-j zO^J!##`2-{fAs~2p%@Z_-N`gIa?~O>3*5A49hDbO7i=V^-O$+sm;kJL=vY8wbM-jr z0nO^%wM(A)pk`6tciY;h(0m&3c>sdS3Fb`8Hgr+nvugya9i?1T zNB?Pl1I7cUZOo7iW3GD(d`}s9<9w0to)njQtPP9oN;b7PAAvqnqkE|zM{6C6(P8al9o`%k|=zC6M$UTkI7%Kwgx!7n)>&4GEB-rP~`x~+-m2~2gjZp%a9q1S5H@~|M8Izv`P@++|Bw&e; zMJjf$M8(6iAg+XZ+fz{lqI>5Dwk_)oi1)W7lFP%=X_Yrs=J<3mQ?d*yTY1~emkg6k zwNl~-!VC%ZqhpFCaufmq5j+c!3&u8MS#p)FQfH;zk9C~I7zcP9LAROUH8DB-N?dOHI%ITg3Lg~z9X_5+p*aJP zi27`;eu2$DIE?6lWpa8*RFwEvXdx$P8FX89{wlVAjewHHDB7#Di&6I)p(Xy;T7|20 zV2Nujgk)UG%HY;qtT>B& z)3+cn|3NQ9dXEP3nHeKhoh_I-R6JQ~lB7ZzoQ%8|A*ex7j?BvsT5wjuk*xDopQNZeyOo zry>0UU?`q?J8m=%t)tCEfh?9jtbpfP5#`YP|J)v3@F%5zlOHujHsxo#h7o z4$_x(~m0h z@q5s_4yXu$T3M1^y(};3?~#s$~+8X2dHKRQuHXiC>N5s)XgY)uY zw~&-?0jvXPfL4Hv-=$|q2V`9YPW7aD-Y#*&b5Nrgl#i8@Xk`IYfad|10G9#CXZ$U| z4Mmw&_&aFAJdab-y)`6r)SVQc6ji%Xge;gJ@I`&9Qm?2?_2G-(V*!7>Cd|Th^bN!V WYr^#*HK str: return ids[0] +def login_admin(api: requests.Session) -> None: + r = api.post( + f"{BASE}/api/auth/login", + json={"username": "Admin", "password": "admin"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200, r.text + assert r.json().get("user", {}).get("username") == "Admin" + + @pytest.fixture(scope="module") def api(): session = requests.Session() @@ -41,6 +51,7 @@ def api(): time.sleep(0.2) else: pytest.fail(f"Server not ready at {BASE}") + login_admin(session) return session @@ -78,6 +89,38 @@ def test_health(api): assert r.json()["ok"] is True +def test_auth_me(api): + r = api.get(f"{BASE}/api/auth/me", timeout=TIMEOUT) + assert r.status_code == 200 + user = r.json().get("user", {}) + assert user.get("username") == "Admin" + assert user.get("permissions", {}).get("missions") == "write" + + +def test_auth_unauthorized_without_session(): + session = requests.Session() + r = session.get(f"{BASE}/api/missions", timeout=TIMEOUT) + assert r.status_code == 401 + + +def test_auth_user_read_only_missions(): + session = requests.Session() + login = session.post( + f"{BASE}/api/auth/login", + json={"username": "User", "password": "user"}, + timeout=TIMEOUT, + ) + assert login.status_code == 200 + listed = session.get(f"{BASE}/api/missions", timeout=TIMEOUT) + assert listed.status_code == 200 + created = session.post( + f"{BASE}/api/triggers", + json={"name": "deny-trigger", "coil_id": 1009, "mission_id": "testmission00001"}, + timeout=TIMEOUT, + ) + assert created.status_code == 403 + + def test_missions_available(api, mission_id): r = api.get(f"{BASE}/api/missions", timeout=TIMEOUT) assert r.status_code == 200 diff --git a/www/app.js b/www/app.js index 35ea719..e68aca4 100644 --- a/www/app.js +++ b/www/app.js @@ -123,6 +123,10 @@ const state = { function setActivePage(page) { const valid = ["dashboard", "config", "missions", "integrations"]; let p = valid.includes(page) ? page : "config"; + if (window.AuthApp && !window.AuthApp.canAccessPage(p)) { + const fallback = valid.find((v) => window.AuthApp.canAccessPage(v)); + p = fallback || "dashboard"; + } if (page === "overview") p = "dashboard"; navItemEls.forEach((a) => { const on = (a.dataset.page || "") === p; @@ -562,6 +566,7 @@ function setStatus(msg) { async function api(path, opts = {}) { const res = await fetch(path, { + credentials: "include", headers: { "Content-Type": "application/json" }, ...opts, }); @@ -3359,7 +3364,8 @@ function initRobotModelPanelCollapse() { } initLayoutManagerEvents(); -initNavigation(); +if (window.AuthApp?.isReady()) initNavigation(); +else window.addEventListener("lm:auth-ready", () => initNavigation(), { once: true }); initSplitPane(); initLidarForm(); initMotorWheelsEvents(); @@ -3402,21 +3408,25 @@ saveLayoutBtn.addEventListener("click", async () => { }); (async () => { - try { - await api("/api/health"); - await loadMotorCatalog(); - await loadAll(); - selectedText.textContent = "none"; - selectedRelText.textContent = "—"; - setStatus("Sẵn sàng"); - } catch (e) { - const msg = String(e.message || e); - if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`; - if (msg.includes("stack") || msg.includes("Maximum call")) { - setStatus(`Lỗi JavaScript: ${msg}`); - } else { - setStatus(`Không kết nối được backend: ${msg}`); + const boot = async () => { + try { + await api("/api/health"); + await loadMotorCatalog(); + await loadAll(); + selectedText.textContent = "none"; + selectedRelText.textContent = "—"; + setStatus("Sẵn sàng"); + } catch (e) { + const msg = String(e.message || e); + if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`; + if (msg.includes("stack") || msg.includes("Maximum call")) { + setStatus(`Lỗi JavaScript: ${msg}`); + } else { + setStatus(`Không kết nối được backend: ${msg}`); + } } - } + }; + if (window.AuthApp?.isReady()) await boot(); + else window.AuthApp?.whenReady(() => { boot(); }); })(); diff --git a/www/auth.js b/www/auth.js new file mode 100644 index 0000000..454cb4f --- /dev/null +++ b/www/auth.js @@ -0,0 +1,372 @@ +(() => { + const el = (id) => document.getElementById(id); + + const loginScreenEl = el("loginScreen"); + const shellEl = document.querySelector(".shell"); + const loginFormEl = el("loginForm"); + const loginPanelPasswordEl = el("loginPanelPassword"); + const loginPanelPinEl = el("loginPanelPin"); + const loginKeypadEl = el("loginKeypad"); + const loginPinHiddenEl = el("loginPin"); + const loginErrorEl = el("loginError"); + const loginPinErrorEl = el("loginPinError"); + const loginTabPasswordEl = el("loginTabPassword"); + const loginTabPinEl = el("loginTabPin"); + const userMenuBtnEl = el("userMenuBtn"); + const userMenuPanelEl = el("userMenuPanel"); + const userMenuNameEl = el("userMenuName"); + const userMenuGroupEl = el("userMenuGroup"); + const changePasswordDialogEl = el("changePasswordDialog"); + const changePasswordFormEl = el("changePasswordForm"); + const changePasswordErrorEl = el("changePasswordError"); + + let currentUser = null; + let ready = false; + let loginMode = "password"; + let pinDigits = []; + let pinSubmitting = false; + + async function apiJson(path, opts = {}) { + const res = await fetch(path, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, + ...opts, + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + if (!res.ok) { + const msg = (data && data.error) || text || res.statusText; + throw new Error(msg); + } + return data; + } + + function showError(msg, mode = loginMode) { + const target = mode === "pin" ? loginPinErrorEl : loginErrorEl; + const other = mode === "pin" ? loginErrorEl : loginPinErrorEl; + if (other) { + other.textContent = ""; + other.setAttribute("hidden", ""); + } + if (!target) return; + target.textContent = msg || ""; + if (msg) target.removeAttribute("hidden"); + else target.setAttribute("hidden", ""); + } + + function renderPinCells() { + document.querySelectorAll(".loginPinCell").forEach((cell, index) => { + cell.classList.toggle("filled", index < pinDigits.length); + cell.classList.toggle("active", index === pinDigits.length && pinDigits.length < 4); + }); + } + + function resetPin() { + pinDigits = []; + pinSubmitting = false; + if (loginPinHiddenEl) loginPinHiddenEl.value = ""; + renderPinCells(); + showError("", "pin"); + } + + async function submitPinFromKeypad() { + if (pinSubmitting || pinDigits.length !== 4) return; + pinSubmitting = true; + setLoginLoading(true); + showError("", "pin"); + try { + await loginPin(pinDigits.join("")); + } catch (e) { + const msg = String(e.message || ""); + if (msg.includes("invalid pin") || msg.includes("401")) { + showError("Mã PIN không hợp lệ. Liên hệ quản trị viên.", "pin"); + } else { + showError(msg || "Mã PIN không hợp lệ", "pin"); + } + resetPin(); + setLoginLoading(false); + } + } + + function appendPinDigit(digit) { + if (pinSubmitting || pinDigits.length >= 4) return; + pinDigits.push(digit); + if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join(""); + renderPinCells(); + if (pinDigits.length === 4) submitPinFromKeypad(); + } + + function backspacePin() { + if (pinSubmitting) return; + pinDigits.pop(); + if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join(""); + renderPinCells(); + showError("", "pin"); + } + + function setLoginLoading(loading) { + loginScreenEl?.classList.toggle("is-loading", loading); + document.querySelectorAll(".loginSubmitLabel").forEach((label) => { + label.textContent = loading ? "Đang đăng nhập…" : "Đăng nhập"; + }); + } + + function setLoginMode(mode) { + loginMode = mode; + const pin = mode === "pin"; + loginPanelPasswordEl?.toggleAttribute("hidden", pin); + loginPanelPinEl?.toggleAttribute("hidden", !pin); + loginTabPasswordEl?.classList.toggle("active", !pin); + loginTabPinEl?.classList.toggle("active", pin); + loginTabPasswordEl?.setAttribute("aria-selected", pin ? "false" : "true"); + loginTabPinEl?.setAttribute("aria-selected", pin ? "true" : "false"); + showError("", "password"); + showError("", "pin"); + if (pin) { + resetPin(); + renderPinCells(); + } + } + + function permissionLevel(resource) { + const perms = currentUser?.permissions || {}; + return perms[resource] || "none"; + } + + function canAccessPage(page) { + const map = { + dashboard: "dashboard", + config: "config", + missions: "missions", + integrations: "integrations", + }; + const resource = map[page]; + if (!resource) return true; + return permissionLevel(resource) !== "none"; + } + + function canWrite(resource) { + return permissionLevel(resource) === "write"; + } + + function applyNavPermissions() { + document.querySelectorAll(".navItem[data-page]").forEach((a) => { + const page = a.dataset.page || ""; + const allowed = canAccessPage(page); + a.hidden = !allowed; + a.style.display = allowed ? "" : "none"; + }); + document.body.classList.toggle("auth-readonly-config", !canWrite("config")); + document.body.classList.toggle("auth-readonly-missions", !canWrite("missions")); + document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations")); + } + + function updateUserMenu() { + if (!currentUser) return; + if (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—"; + if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—"; + if (userMenuBtnEl) { + const label = currentUser.display_name || currentUser.username || "User"; + userMenuBtnEl.textContent = label; + userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`; + } + } + + function unlockApp() { + setLoginLoading(false); + if (loginScreenEl) { + loginScreenEl.setAttribute("hidden", ""); + loginScreenEl.style.display = "none"; + } + if (shellEl) { + shellEl.classList.remove("auth-locked"); + shellEl.style.display = ""; + } + applyNavPermissions(); + updateUserMenu(); + ready = true; + window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } })); + } + + function lockApp() { + ready = false; + currentUser = null; + if (shellEl) shellEl.classList.add("auth-locked"); + if (loginScreenEl) { + loginScreenEl.removeAttribute("hidden"); + loginScreenEl.style.display = ""; + } + showError("", "password"); + showError("", "pin"); + resetPin(); + } + + async function tryRestoreSession() { + try { + const data = await apiJson("/api/auth/me"); + currentUser = data.user; + unlockApp(); + return true; + } catch { + lockApp(); + return false; + } + } + + async function loginPassword(username, password) { + showError("", "password"); + const data = await apiJson("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + currentUser = data.user; + unlockApp(); + } + + async function loginPin(pin) { + showError("", "pin"); + const data = await apiJson("/api/auth/login", { + method: "POST", + body: JSON.stringify({ pin }), + }); + currentUser = data.user; + unlockApp(); + } + + async function logout() { + try { + await apiJson("/api/auth/logout", { method: "POST", body: "{}" }); + } catch { + /* ignore */ + } + currentUser = null; + ready = false; + lockApp(); + userMenuPanelEl?.setAttribute("hidden", ""); + window.dispatchEvent(new Event("lm:auth-logout")); + } + + function bindEvents() { + loginTabPasswordEl?.addEventListener("click", (evt) => { + evt.preventDefault(); + setLoginMode("password"); + }); + loginTabPinEl?.addEventListener("click", (evt) => { + evt.preventDefault(); + setLoginMode("pin"); + }); + + loginFormEl?.addEventListener("submit", async (evt) => { + evt.preventDefault(); + const username = el("loginUsername")?.value?.trim() || ""; + const password = el("loginPasswordInput")?.value || ""; + if (!username || !password) { + showError("Nhập tên đăng nhập và mật khẩu", "password"); + return; + } + setLoginLoading(true); + showError("", "password"); + try { + await loginPassword(username, password); + } catch (e) { + const msg = String(e.message || ""); + if (msg.includes("credentials") || msg.includes("401")) { + showError("Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin", "password"); + } else if (msg.includes("fetch") || msg.includes("Failed")) { + showError("Không kết nối được server. Kiểm tra http://localhost:8080", "password"); + } else { + showError(msg || "Đăng nhập thất bại", "password"); + } + setLoginLoading(false); + } + }); + + loginKeypadEl?.addEventListener("click", (evt) => { + const btn = evt.target.closest("[data-key]"); + if (!btn || pinSubmitting) return; + const key = btn.getAttribute("data-key"); + if (key === "back") { + backspacePin(); + return; + } + if (/^[0-9]$/.test(key)) appendPinDigit(key); + }); + + document.addEventListener("keydown", (evt) => { + if (loginMode !== "pin" || pinSubmitting) return; + if (/^[0-9]$/.test(evt.key)) { + evt.preventDefault(); + appendPinDigit(evt.key); + } else if (evt.key === "Backspace") { + evt.preventDefault(); + backspacePin(); + } + }); + + userMenuBtnEl?.addEventListener("click", (evt) => { + evt.stopPropagation(); + const open = userMenuPanelEl?.hasAttribute("hidden"); + if (open) userMenuPanelEl?.removeAttribute("hidden"); + else userMenuPanelEl?.setAttribute("hidden", ""); + }); + + document.addEventListener("click", () => { + userMenuPanelEl?.setAttribute("hidden", ""); + }); + + el("userMenuSignOutBtn")?.addEventListener("click", (evt) => { + evt.preventDefault(); + logout(); + }); + + el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => { + evt.preventDefault(); + userMenuPanelEl?.setAttribute("hidden", ""); + changePasswordErrorEl && (changePasswordErrorEl.textContent = ""); + changePasswordDialogEl?.showModal(); + }); + + changePasswordFormEl?.addEventListener("submit", async (evt) => { + evt.preventDefault(); + const current = el("changePasswordCurrent")?.value || ""; + const next = el("changePasswordNew")?.value || ""; + const confirm = el("changePasswordConfirm")?.value || ""; + if (next !== confirm) { + if (changePasswordErrorEl) changePasswordErrorEl.textContent = "Mật khẩu mới không khớp"; + return; + } + try { + await apiJson("/api/auth/password", { + method: "PUT", + body: JSON.stringify({ current_password: current, new_password: next }), + }); + changePasswordDialogEl?.close(); + changePasswordFormEl.reset(); + } catch (e) { + if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || "Đổi mật khẩu thất bại"; + } + }); + } + + window.AuthApp = { + isReady: () => ready, + getUser: () => currentUser, + canAccessPage, + canWrite, + logout, + whenReady(fn) { + if (ready) fn(currentUser); + else window.addEventListener("lm:auth-ready", () => fn(currentUser), { once: true }); + }, + }; + + bindEvents(); + setLoginMode("password"); + shellEl?.classList.add("auth-locked"); + tryRestoreSession(); +})(); diff --git a/www/dashboard.js b/www/dashboard.js index 99b2588..2f1df02 100644 --- a/www/dashboard.js +++ b/www/dashboard.js @@ -349,6 +349,7 @@ } function startDashboardPoll() { + if (window.AuthApp && !window.AuthApp.isReady()) return; stopDashboardPoll(); missions()?.refreshQueue?.(); store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets()); @@ -386,5 +387,10 @@ }, }; - init(); + function boot() { + init(); + } + if (window.AuthApp?.isReady()) boot(); + else window.addEventListener("lm:auth-ready", boot, { once: true }); + window.addEventListener("lm:auth-logout", stopDashboardPoll); })(); diff --git a/www/index.html b/www/index.html index 49663fd..6917bcc 100644 --- a/www/index.html +++ b/www/index.html @@ -7,7 +7,88 @@ -
+
+
+
+
RobotApp
+
+ Chọn cách đăng nhập: +
+ + +
+
+
+ +
+
+
+

Đăng nhập bằng tên và mật khẩu

+

Nhập tên đăng nhập và mật khẩu để truy cập robot.

+

Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.

+

Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.

+
+
+
+ + + +
+ +
+
+ + +
+
+
+ +