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 6c22837..ddc6c53 100644 Binary files a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc and b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc differ diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py index 6bbf555..df99fed 100644 --- a/tests/test_api_integration.py +++ b/tests/test_api_integration.py @@ -26,6 +26,16 @@ def resolve_mission_id(api: requests.Session) -> 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.

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