From 2c6193290f361862930232b8ac57260e46d1e865 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 24 Aug 2026 10:22:45 -0400 Subject: [PATCH] feat(auth): require bootstrap password change --- README.md | 10 +- config/lightnvr.ini | 3 +- .../0063_add_must_change_password.sql | 13 ++ docs/API.md | 17 ++ docs/ARCHITECTURE.md | 2 + docs/CONFIGURATION.md | 11 +- docs/DOCKER.md | 13 +- docs/README.md | 3 +- docs/TROUBLESHOOTING.md | 5 +- docs/TROUBLESHOOTING_WEB_INTERFACE.md | 13 +- include/database/db_auth.h | 1 + include/database/db_embedded_migrations.h | 17 +- src/core/config.c | 2 +- src/database/db_auth.c | 73 ++++++- src/web/api_handlers_auth_backend_agnostic.c | 13 +- src/web/api_handlers_users_backend_agnostic.c | 34 ++- src/web/httpd_utils.c | 32 ++- tests/unit/test_db_auth.c | 75 ++++++- tests/unit/test_httpd_utils.c | 199 +++++++++++++++++- web/js/components/preact/AuthGate.jsx | 171 +++++++++++++++ web/js/components/preact/LoginView.jsx | 10 +- web/js/components/preact/SetupWizard.jsx | 6 +- .../components/preact/forcedPasswordChange.js | 7 + web/js/pages/hls-page.jsx | 5 +- web/js/pages/index-page.jsx | 11 +- web/js/pages/investigation-page.jsx | 11 +- web/js/pages/recordings-page.jsx | 17 +- web/js/pages/settings-page.jsx | 11 +- web/js/pages/streams-page.jsx | 11 +- web/js/pages/system-page.jsx | 11 +- web/js/pages/timeline-page.jsx | 11 +- web/js/pages/users-page.jsx | 11 +- web/js/utils/auth-utils.js | 7 +- web/public/locales/en.json | 16 +- web/tests/forcedPasswordChange.spec.js | 25 +++ 35 files changed, 788 insertions(+), 89 deletions(-) create mode 100644 db/migrations/0063_add_must_change_password.sql create mode 100644 web/js/components/preact/AuthGate.jsx create mode 100644 web/js/components/preact/forcedPasswordChange.js create mode 100644 web/tests/forcedPasswordChange.spec.js diff --git a/README.md b/README.md index 408a4db37..52441a606 100644 --- a/README.md +++ b/README.md @@ -301,8 +301,10 @@ Powerful object detection using modern ONNX and TFLite models with zone-aware fi > `0.0.0.0` by default.** Until you change the password, anyone who can reach port 8080 can > reach your cameras and recordings. Change it before you expose the port to anything. -Log in at `http://your-device-ip:8080` with `admin` / `admin`, then go to **Settings → -Users** and set a real password. +Log in at `http://your-device-ip:8080` with `admin` / `admin`. LightNVR immediately +opens a blocking password-change screen; the initial session cannot use the rest of the +UI or protected password-authenticated APIs. After replacing the password, sign in again +with the new credential. MFA, when enabled, is evaluated on that next sign-in. You can also pre-set the password *before* the first start, which avoids the default ever being valid: set `password` in the `[web]` section of `lightnvr.ini` and start LightNVR. @@ -310,6 +312,10 @@ The first run creates the admin account with that password instead. The setting read when the account is created — after that, users live in the database and are managed from the **Users** page. +Upgrades do not mark existing accounts for a forced password change. The gate is set only +when LightNVR creates a new administrator with the fallback `admin` password. API-key +authentication and demo mode are not restricted by this first-login UI flow. + Forgot the password? There is no reset flag. Stop LightNVR, delete the account row, and restart — it will be recreated from the same rules as a first run: diff --git a/config/lightnvr.ini b/config/lightnvr.ini index 7a01af7a7..ffe5d898a 100755 --- a/config/lightnvr.ini +++ b/config/lightnvr.ini @@ -40,7 +40,8 @@ port = 8080 root = /var/lib/lightnvr/www auth_enabled = true username = admin -; Password is auto-generated on first run - check logs for the generated password +; Only read when the admin account is first created. If blank, admin/admin is used +; and the UI requires that password to be replaced before continuing. ; password = auth_timeout_hours = 24 ; Session timeout in hours (default: 24) ; trusted_proxy_cidrs = 127.0.0.1/32,::1/128 ; Only trust X-Forwarded-For from these reverse proxies diff --git a/db/migrations/0063_add_must_change_password.sql b/db/migrations/0063_add_must_change_password.sql new file mode 100644 index 000000000..9665fd0f1 --- /dev/null +++ b/db/migrations/0063_add_must_change_password.sql @@ -0,0 +1,13 @@ +-- Require a freshly bootstrapped default administrator to replace admin/admin. + +-- migrate:up + +ALTER TABLE users +ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0 +CHECK (must_change_password IN (0, 1)); + +-- migrate:down + +-- Existing installations deliberately remain unflagged because the default is +-- zero and SQLite migration rollback does not need to rebuild the users table. +SELECT 1; diff --git a/docs/API.md b/docs/API.md index 9465ea852..ad5f6bd08 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1522,6 +1522,20 @@ Authenticates a user and creates a session. } ``` +**Success Response:** +```json +{ + "success": true, + "redirect": "/index.html", + "must_change_password": false +} +``` + +On a fresh installation created with the fallback `admin` password, +`must_change_password` is `true`. That password-authenticated session can only read +`/api/auth/verify` and change its own password until the replacement succeeds. MFA is +deferred until the next login. Demo mode and API-key authentication are unaffected. + #### Login with TOTP ``` @@ -1555,6 +1569,9 @@ GET /api/auth/verify Verifies that the current session is valid. +The response includes `must_change_password`, allowing the blocking first-login flow to +recover safely after a refresh. + ### User Management #### List Users diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36c30ed73..f7170bea8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -483,6 +483,8 @@ CREATE TABLE users ( api_key TEXT, -- Password lock (migration 0019) password_change_locked INTEGER DEFAULT 0, + -- Fresh fallback-administrator gate (migration 0063) + must_change_password INTEGER NOT NULL DEFAULT 0, -- TOTP/MFA (migration 0021) totp_secret TEXT, totp_enabled INTEGER DEFAULT 0 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cf0088068..3471b4854 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -247,8 +247,15 @@ web_thread_pool_size = 8 > ⚠️ **Default credentials are `admin` / `admin`.** Combined with the default > `bind_ip = 0.0.0.0`, anyone who can reach port 8080 can reach your cameras and -> recordings until you change the password. Either set `password` before the very first -> start, or log in and change it under **Settings → Users** immediately. +> recordings if an attacker completes first login before you do. Either set `password` +> before the very first start, or log in with `admin` / `admin` and complete the mandatory +> password-change screen. Until the change succeeds, that password-authenticated session +> can only verify its state and change its own password; MFA is evaluated after the new +> password is set and the user signs in again. +> +> The requirement is added only when a new administrator is created with the fallback +> password. Existing users are not flagged on upgrade, and API-key authentication and demo +> mode are unaffected. > > There is no password-reset flag. If you lose the admin password, stop LightNVR, delete > the account row, and restart — it is recreated by the same first-run rules: diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 6a2710e34..ef07856b5 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -345,15 +345,21 @@ On first container start, the entrypoint script automatically: - **Username:** `admin` - **Password:** `admin` -⚠️ **Change these immediately after first login.** The web server binds `0.0.0.0` inside -the container, so once you publish port 8080 these credentials are the only thing in front -of your cameras and recordings. Change the password under **Settings → Users**. +⚠️ The web server binds `0.0.0.0` inside the container, so once you publish port 8080 +these credentials are the only thing in front of the first-login flow. LightNVR requires +the default password to be replaced immediately after login and blocks the rest of the UI +and password-authenticated APIs until that succeeds. You then sign in again with the new +password; MFA, when enabled, follows on that sign-in. To avoid the default ever being valid, set `password` in the `[web]` section of `config/lightnvr.ini` *before* the first start — the admin account is then created with that password instead. The setting is only read when the account is created; afterwards users are managed from the **Users** page. +Existing accounts are not flagged during an upgrade. API-key authentication and demo +mode remain available while a freshly bootstrapped administrator is awaiting a password +change. + ## WebRTC Configuration The container includes pre-configured WebRTC support with STUN servers for NAT traversal. @@ -608,4 +614,3 @@ docker run -d \ For issues and questions: - GitHub Issues: https://github.com/opensensor/lightNVR/issues - Documentation: https://github.com/opensensor/lightNVR/tree/main/docs - diff --git a/docs/README.md b/docs/README.md index 84227cd90..14f78ebee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,8 @@ will need them. - [Home Assistant add-on](HOME_ASSISTANT.md) — for Home Assistant OS / Supervised - [Windows via Podman + WSL2](WINDOWS_PODMAN.md) - [Build from source](BUILD.md) — for embedded targets or development -2. **Log in and change the password.** The default is `admin` / `admin` and the web server +2. **Log in and complete the required password change.** The default is `admin` / `admin`; + a new installation blocks the rest of the UI until you replace it. The web server listens on all interfaces — see the warning in [CONFIGURATION.md](CONFIGURATION.md#web-server-settings). 3. **Add a camera.** Use ONVIF discovery if your cameras support it ([ONVIF_DETECTION.md](ONVIF_DETECTION.md)); otherwise add the RTSP URL by hand. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0ad42bbf9..15e8d9734 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -346,7 +346,10 @@ when the admin account is first created. sudo systemctl start lightnvr ``` -4. Log back in and set a real password under **Settings → Users**. +4. Log back in. If the fallback `admin` password was used, complete the mandatory + password-change screen and then sign in with the new password. If `[web] password` was + supplied before the account was recreated, that operator-selected credential skips the + first-login gate. ## Performance Optimization diff --git a/docs/TROUBLESHOOTING_WEB_INTERFACE.md b/docs/TROUBLESHOOTING_WEB_INTERFACE.md index e98a6a0e3..26d7cadf4 100644 --- a/docs/TROUBLESHOOTING_WEB_INTERFACE.md +++ b/docs/TROUBLESHOOTING_WEB_INTERFACE.md @@ -292,14 +292,11 @@ sudo cat /etc/lightnvr/lightnvr.ini | grep -A 2 "\[web\]" - Username: `admin` - Password: `admin` -3. If you changed the password and forgot it, reset in config: -```bash -sudo nano /etc/lightnvr/lightnvr.ini -``` -Change the password line under `[web]` section, then: -```bash -sudo systemctl restart lightnvr -``` +3. If you changed the password and forgot it, changing `[web] password` no longer updates + an existing database user. Follow the account recreation procedure in + [Authentication Issues](TROUBLESHOOTING.md#authentication-issues). An account recreated + with fallback `admin` / `admin` must complete the blocking password-change screen; + supplying `[web] password` before recreation skips that gate. ## Getting More Help diff --git a/include/database/db_auth.h b/include/database/db_auth.h index f315644de..af881825d 100644 --- a/include/database/db_auth.h +++ b/include/database/db_auth.h @@ -40,6 +40,7 @@ typedef struct { int64_t last_login; /**< Last login timestamp */ bool is_active; /**< Whether the user is active */ bool password_change_locked; /**< Whether password changes are locked (for demo accounts) */ + bool must_change_password; /**< Whether password auth is restricted pending a password change */ bool totp_enabled; /**< Whether TOTP MFA is enabled */ char allowed_tags[USER_ALLOWED_TAGS_MAX]; /**< Comma-separated tag whitelist for RBAC (empty = no restriction) */ bool has_tag_restriction; /**< Whether allowed_tags is set (true) or NULL/unrestricted (false) */ diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index 0380e91bf..3bf5c8bae 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -1271,6 +1271,14 @@ static const char migration_0062_down[] = "DROP INDEX IF EXISTS idx_detections_camera_zone_time_id;\n" "DROP INDEX IF EXISTS idx_detections_camera_label_time_id;"; +static const char migration_0063_up[] = + "ALTER TABLE users " + "ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0 " + "CHECK (must_change_password IN (0, 1));"; + +static const char migration_0063_down[] = + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -1706,8 +1714,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0062_down, .is_embedded = true }, + { + .version = "0063", + .description = "add_must_change_password", + .sql_up = migration_0063_up, + .sql_down = migration_0063_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 62 +#define EMBEDDED_MIGRATIONS_COUNT 63 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/src/core/config.c b/src/core/config.c index dbfd2e5f8..b4ffbcf4a 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -376,7 +376,7 @@ void load_default_config(config_t *config) { safe_strcpy(config->web_root, "/var/lib/lightnvr/www", MAX_PATH_LENGTH, 0); config->web_auth_enabled = true; safe_strcpy(config->web_username, "admin", 32, 0); - // No default password - will be generated randomly on first run + // Blank means bootstrap admin/admin; db_auth_init requires first-login replacement. config->web_password[0] = '\0'; config->webrtc_disabled = false; // WebRTC is enabled by default config->hls_disabled = false; // HLS is enabled by default (#397) diff --git a/src/database/db_auth.c b/src/database/db_auth.c index b32aa6c43..ed53a3a84 100644 --- a/src/database/db_auth.c +++ b/src/database/db_auth.c @@ -289,12 +289,14 @@ static int prepare_user_lookup_stmt(sqlite3 *db, const char *where_clause, sqlit bool has_allowed_tags = cached_column_exists("users", "allowed_tags"); bool has_allowed_login_cidrs = cached_column_exists("users", "allowed_login_cidrs"); bool has_authorization_mode = cached_column_exists("users", "authorization_mode"); + bool has_must_change_password = cached_column_exists("users", "must_change_password"); char sql[768]; int written = snprintf(sql, sizeof(sql), "SELECT id, username, email, role, api_key, created_at, " - "updated_at, last_login, is_active, password_change_locked, %s, %s, %s, %s " + "updated_at, last_login, is_active, password_change_locked, %s, %s, %s, %s, %s " "FROM users %s;", + has_must_change_password ? "must_change_password" : "0", has_totp ? "totp_enabled" : "0", has_allowed_tags ? "allowed_tags" : "NULL", has_allowed_login_cidrs ? "allowed_login_cidrs" : "NULL", @@ -330,21 +332,22 @@ static void populate_user_from_stmt(sqlite3_stmt *stmt, user_t *user) { user->last_login = sqlite3_column_int64(stmt, 7); user->is_active = sqlite3_column_int(stmt, 8) != 0; user->password_change_locked = sqlite3_column_int(stmt, 9) != 0; - user->totp_enabled = sqlite3_column_int(stmt, 10) != 0; + user->must_change_password = sqlite3_column_int(stmt, 10) != 0; + user->totp_enabled = sqlite3_column_int(stmt, 11) != 0; - const char *allowed_tags = (const char *)sqlite3_column_text(stmt, 11); + const char *allowed_tags = (const char *)sqlite3_column_text(stmt, 12); if (allowed_tags && allowed_tags[0] != '\0') { safe_strcpy(user->allowed_tags, allowed_tags, sizeof(user->allowed_tags), 0); user->has_tag_restriction = true; } - const char *allowed_login_cidrs = (const char *)sqlite3_column_text(stmt, 12); + const char *allowed_login_cidrs = (const char *)sqlite3_column_text(stmt, 13); if (allowed_login_cidrs && allowed_login_cidrs[0] != '\0') { safe_strcpy(user->allowed_login_cidrs, allowed_login_cidrs, sizeof(user->allowed_login_cidrs), 0); user->has_login_cidr_restriction = true; } - const char *authorization_mode = (const char *)sqlite3_column_text(stmt, 13); + const char *authorization_mode = (const char *)sqlite3_column_text(stmt, 14); safe_strcpy(user->authorization_mode, authorization_mode ? authorization_mode : "legacy", sizeof(user->authorization_mode), 0); @@ -534,12 +537,52 @@ int db_auth_init(void) { log_info("Creating default admin user with default password"); } - rc = db_auth_create_user("admin", initial_password, NULL, USER_ROLE_ADMIN, true, NULL); + sqlite3 *db = get_db_handle(); + if (!db || sqlite3_exec(db, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + log_error("Failed to begin default administrator creation transaction"); + return -1; + } + + int64_t admin_user_id = 0; + rc = db_auth_create_user("admin", initial_password, NULL, USER_ROLE_ADMIN, + true, &admin_user_id); if (rc != 0) { + sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); log_error("Failed to create default admin user"); return -1; } + if (!used_config_password) { + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2( + db, + "UPDATE users SET must_change_password = 1, updated_at = ? WHERE id = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + log_error("Failed to prepare default password-change requirement: %s", + sqlite3_errmsg(db)); + sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); + return -1; + } + sqlite3_bind_int64(stmt, 1, (sqlite3_int64)time(NULL)); + sqlite3_bind_int64(stmt, 2, admin_user_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + log_error("Failed to require a default administrator password change: %s", + sqlite3_errmsg(db)); + sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); + return -1; + } + } + + if (sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + log_error("Failed to commit default administrator creation: %s", + sqlite3_errmsg(db)); + sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); + return -1; + } + // Report the credential that is actually valid. Announcing "admin" when the // operator supplied their own password sends them chasing a login that does // not work, and prints a password they never chose into the log. @@ -551,7 +594,7 @@ int db_auth_init(void) { log_info("*** Manage users from Settings -> Users ***"); } else { log_info("*** Password: admin ***"); - log_info("*** PLEASE CHANGE THIS PASSWORD IMMEDIATELY! ***"); + log_info("*** Password change required on first login ***"); } log_info("********************************************************"); @@ -816,7 +859,10 @@ int db_auth_change_password(int64_t user_id, const char *new_password) { // Check if the user exists and if password changes are locked sqlite3_stmt *stmt; - int rc = sqlite3_prepare_v2(db, "SELECT id, password_change_locked FROM users WHERE id = ?;", -1, &stmt, NULL); + int rc = sqlite3_prepare_v2( + db, + "SELECT id, password_change_locked, must_change_password FROM users WHERE id = ?;", + -1, &stmt, NULL); if (rc != SQLITE_OK) { log_error("Failed to prepare statement: %s", sqlite3_errmsg(db)); return -1; @@ -838,6 +884,14 @@ int db_auth_change_password(int64_t user_id, const char *new_password) { return -2; // Special error code for locked password } + bool must_change_password = sqlite3_column_int(stmt, 2) != 0; + if (must_change_password && strcmp(new_password, "admin") == 0) { + log_warn("Default password cannot satisfy required password change for user: %lld", + (long long)user_id); + sqlite3_finalize(stmt); + return -3; + } + sqlite3_finalize(stmt); // Generate a new salt @@ -894,7 +948,8 @@ int db_auth_change_password(int64_t user_id, const char *new_password) { // Update the password rc = sqlite3_prepare_v2(db, - "UPDATE users SET password_hash = ?, salt = ?, updated_at = ? " + "UPDATE users SET password_hash = ?, salt = ?, updated_at = ?, " + "must_change_password = CASE WHEN must_change_password = 1 THEN 0 ELSE must_change_password END " "WHERE id = ?;", -1, &stmt, NULL); if (rc != SQLITE_OK) { diff --git a/src/web/api_handlers_auth_backend_agnostic.c b/src/web/api_handlers_auth_backend_agnostic.c index 88b461436..42fc143e7 100644 --- a/src/web/api_handlers_auth_backend_agnostic.c +++ b/src/web/api_handlers_auth_backend_agnostic.c @@ -363,8 +363,13 @@ void handle_auth_login(const http_request_t *req, http_response_t *res) { return; } + bool must_change_password = + authenticated_user.must_change_password && !g_config.demo_mode; + // Check if user has TOTP enabled (only for API/JSON requests) - if (!is_form) { + // A bootstrap password must be replaced before MFA is evaluated. The + // restricted session created below cannot access any other protected API. + if (!is_form && !must_change_password) { char totp_secret[64] = {0}; bool totp_enabled = false; if (db_auth_get_totp_info(user_id, totp_secret, sizeof(totp_secret), &totp_enabled) == 0 && totp_enabled) { @@ -497,6 +502,8 @@ void handle_auth_login(const http_request_t *req, http_response_t *res) { cJSON *response = cJSON_CreateObject(); cJSON_AddBoolToObject(response, "success", true); cJSON_AddStringToObject(response, "redirect", "/index.html"); + cJSON_AddBoolToObject(response, "must_change_password", + must_change_password); char *json_str = cJSON_PrintUnformatted(response); http_response_set_json(res, 200, json_str); @@ -566,6 +573,7 @@ void handle_auth_verify(const http_request_t *req, http_response_t *res) { cJSON_AddBoolToObject(response, "authenticated", true); cJSON_AddStringToObject(response, "username", "admin"); cJSON_AddStringToObject(response, "role", "admin"); + cJSON_AddBoolToObject(response, "must_change_password", false); cJSON_AddBoolToObject(response, "auth_enabled", false); cJSON_AddNumberToObject(response, "auth_timeout_hours", g_config.auth_timeout_hours); cJSON_AddNumberToObject(response, "auth_absolute_timeout_hours", g_config.auth_absolute_timeout_hours); @@ -592,6 +600,8 @@ void handle_auth_verify(const http_request_t *req, http_response_t *res) { cJSON_AddNumberToObject(response, "role_id", user.role); cJSON_AddBoolToObject(response, "is_active", user.is_active); cJSON_AddBoolToObject(response, "password_change_locked", user.password_change_locked); + cJSON_AddBoolToObject(response, "must_change_password", + user.must_change_password && !g_config.demo_mode); cJSON_AddBoolToObject(response, "auth_enabled", true); cJSON_AddNumberToObject(response, "auth_timeout_hours", g_config.auth_timeout_hours); cJSON_AddNumberToObject(response, "auth_absolute_timeout_hours", g_config.auth_absolute_timeout_hours); @@ -613,6 +623,7 @@ void handle_auth_verify(const http_request_t *req, http_response_t *res) { cJSON_AddBoolToObject(response, "demo_mode", true); cJSON_AddStringToObject(response, "username", "demo"); cJSON_AddStringToObject(response, "role", "viewer"); + cJSON_AddBoolToObject(response, "must_change_password", false); char *json_str = cJSON_PrintUnformatted(response); http_response_set_json(res, 200, json_str); diff --git a/src/web/api_handlers_users_backend_agnostic.c b/src/web/api_handlers_users_backend_agnostic.c index 839cfa054..8b9ad069c 100644 --- a/src/web/api_handlers_users_backend_agnostic.c +++ b/src/web/api_handlers_users_backend_agnostic.c @@ -44,6 +44,7 @@ static cJSON *user_to_json(const user_t *user, int include_api_key) { cJSON_AddNumberToObject(json, "last_login", (double)user->last_login); cJSON_AddBoolToObject(json, "is_active", user->is_active); cJSON_AddBoolToObject(json, "password_change_locked", user->password_change_locked); + cJSON_AddBoolToObject(json, "must_change_password", user->must_change_password); cJSON_AddBoolToObject(json, "totp_enabled", user->totp_enabled); cJSON_AddStringToObject( json, "authorization_mode", @@ -74,12 +75,14 @@ static int prepare_user_select_stmt(sqlite3 *db, const char *suffix, sqlite3_stm bool has_allowed_tags = cached_column_exists("users", "allowed_tags"); bool has_allowed_login_cidrs = cached_column_exists("users", "allowed_login_cidrs"); bool has_authorization_mode = cached_column_exists("users", "authorization_mode"); + bool has_must_change_password = cached_column_exists("users", "must_change_password"); char sql[768]; int written = snprintf(sql, sizeof(sql), "SELECT id, username, email, role, api_key, created_at, " - "updated_at, last_login, is_active, password_change_locked, %s, %s, %s, %s " + "updated_at, last_login, is_active, password_change_locked, %s, %s, %s, %s, %s " "FROM users %s;", + has_must_change_password ? "must_change_password" : "0", has_totp ? "totp_enabled" : "0", has_allowed_tags ? "allowed_tags" : "NULL", has_allowed_login_cidrs ? "allowed_login_cidrs" : "NULL", @@ -115,22 +118,23 @@ static void populate_user_from_stmt(sqlite3_stmt *stmt, user_t *user) { user->last_login = sqlite3_column_int64(stmt, 7); user->is_active = sqlite3_column_int(stmt, 8) != 0; user->password_change_locked = sqlite3_column_int(stmt, 9) != 0; - user->totp_enabled = sqlite3_column_int(stmt, 10) != 0; + user->must_change_password = sqlite3_column_int(stmt, 10) != 0; + user->totp_enabled = sqlite3_column_int(stmt, 11) != 0; - const char *allowed_tags = (const char *)sqlite3_column_text(stmt, 11); + const char *allowed_tags = (const char *)sqlite3_column_text(stmt, 12); if (allowed_tags && allowed_tags[0] != '\0') { safe_strcpy(user->allowed_tags, allowed_tags, sizeof(user->allowed_tags), 0); user->has_tag_restriction = true; } - const char *allowed_login_cidrs = (const char *)sqlite3_column_text(stmt, 12); + const char *allowed_login_cidrs = (const char *)sqlite3_column_text(stmt, 13); if (allowed_login_cidrs && allowed_login_cidrs[0] != '\0') { safe_strcpy(user->allowed_login_cidrs, allowed_login_cidrs, sizeof(user->allowed_login_cidrs), 0); user->has_login_cidr_restriction = true; } const char *authorization_mode = - (const char *)sqlite3_column_text(stmt, 13); + (const char *)sqlite3_column_text(stmt, 14); safe_strcpy(user->authorization_mode, authorization_mode ? authorization_mode : "legacy", sizeof(user->authorization_mode), 0); @@ -903,8 +907,13 @@ void handle_users_change_password(const http_request_t *req, http_response_t *re return; } - // Non-admins must provide old password - if (!is_admin) { + // The forced first-login flow always verifies the credential that created + // the restricted session, even though the bootstrap account is an admin. + bool forced_own_change = current_user.must_change_password && + is_own_password && !g_config.demo_mode; + + // Non-admins and forced first-login sessions must provide old password + if (!is_admin || forced_own_change) { if (!old_password_json || !cJSON_IsString(old_password_json)) { cJSON_Delete(json_req); http_response_set_json_error(res, 400, "Current password is required"); @@ -929,6 +938,10 @@ void handle_users_change_password(const http_request_t *req, http_response_t *re cJSON_Delete(json_req); http_response_set_json_error(res, 403, "Password changes are locked for this user"); return; + } else if (rc == -3) { + cJSON_Delete(json_req); + http_response_set_json_error(res, 400, "New password cannot be the default admin password"); + return; } else if (rc != 0) { cJSON_Delete(json_req); http_response_set_json_error(res, 500, "Failed to change password"); @@ -943,10 +956,17 @@ void handle_users_change_password(const http_request_t *req, http_response_t *re // Create JSON response cJSON *response = cJSON_CreateObject(); cJSON_AddBoolToObject(response, "success", true); + cJSON_AddBoolToObject(response, "must_change_password", false); // Send response char *json_str = cJSON_PrintUnformatted(response); http_response_set_json(res, 200, json_str); + if (is_own_password) { + httpd_clear_session_cookie(res); + } + if (forced_own_change) { + httpd_clear_trusted_device_cookie(res); + } // Clean up free(json_str); diff --git a/src/web/httpd_utils.c b/src/web/httpd_utils.c index 9ac9099f2..7c035a8d2 100644 --- a/src/web/httpd_utils.c +++ b/src/web/httpd_utils.c @@ -356,6 +356,26 @@ void httpd_clear_trusted_device_cookie(http_response_t *res) { "trusted_device=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"); } +static bool request_allowed_during_required_password_change( + const http_request_t *req, const user_t *user) { + if (!req || !user || !user->must_change_password || g_config.demo_mode) { + return true; + } + + if (req->method == HTTP_METHOD_GET && + strcmp(req->path, "/api/auth/verify") == 0) { + return true; + } + + char password_path[128]; + int written = snprintf(password_path, sizeof(password_path), + "/api/auth/users/%lld/password", + (long long)user->id); + return written > 0 && (size_t)written < sizeof(password_path) && + req->method == HTTP_METHOD_PUT && + strcmp(req->path, password_path) == 0; +} + static int get_authenticated_user(const http_request_t *req, user_t *user, bool allow_scoped_token) { if (!req || !user) return 0; @@ -389,7 +409,11 @@ static int get_authenticated_user(const http_request_t *req, user_t *user, if (rc == 0) { safe_strcpy(user->authentication_method, "session", sizeof(user->authentication_method), 0); - return 1; + if (request_allowed_during_required_password_change(req, user)) { + return 1; + } + log_warn("Session access restricted pending password change for user '%s'", + user->username); } } else if (rc == 0) { log_warn("Session auth blocked by allowed_login_cidrs for user '%s' from IP %s", @@ -411,7 +435,11 @@ static int get_authenticated_user(const http_request_t *req, user_t *user, if (rc == 0 && db_auth_ip_allowed_for_user(user, effective_client_ip)) { safe_strcpy(user->authentication_method, "basic", sizeof(user->authentication_method), 0); - return 1; + if (request_allowed_during_required_password_change(req, user)) { + return 1; + } + log_warn("Basic auth access restricted pending password change for user '%s'", + user->username); } else if (rc == 0) { log_warn("Basic auth blocked by allowed_login_cidrs for user '%s' from IP %s", user->username, effective_client_ip[0] != '\0' ? effective_client_ip : "(unknown)"); diff --git a/tests/unit/test_db_auth.c b/tests/unit/test_db_auth.c index 8bdf140cb..36da69409 100644 --- a/tests/unit/test_db_auth.c +++ b/tests/unit/test_db_auth.c @@ -20,6 +20,7 @@ #include "utils/strings.h" #include "database/db_core.h" #include "database/db_auth.h" +#include "core/config.h" #define TEST_DB_PATH "/tmp/lightnvr_unit_auth_test.db" @@ -29,9 +30,14 @@ static void clear_users(void) { sqlite3_exec(db, "DELETE FROM users WHERE username != 'admin';", NULL, NULL, NULL); sqlite3_exec(db, "DELETE FROM sessions;", NULL, NULL, NULL); sqlite3_exec(db, "DELETE FROM trusted_devices;", NULL, NULL, NULL); + sqlite3_exec(db, "UPDATE users SET must_change_password = 0;", NULL, NULL, NULL); } -void setUp(void) { clear_users(); } +void setUp(void) { + g_config.web_password[0] = '\0'; + g_config.demo_mode = false; + clear_users(); +} void tearDown(void) {} /* db_auth_init creates default admin */ @@ -45,6 +51,42 @@ void test_auth_init_creates_admin(void) { TEST_ASSERT_EQUAL_INT(USER_ROLE_ADMIN, user.role); } +void test_auth_init_flags_only_fresh_fallback_admin(void) { + sqlite3 *db = get_db_handle(); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec( + db, "DELETE FROM users WHERE username = 'admin';", NULL, NULL, NULL)); + + TEST_ASSERT_EQUAL_INT(0, db_auth_init()); + + user_t user; + TEST_ASSERT_EQUAL_INT(0, db_auth_get_user_by_username("admin", &user)); + TEST_ASSERT_TRUE(user.must_change_password); + TEST_ASSERT_EQUAL_INT(0, db_auth_authenticate("admin", "admin", NULL)); + + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec( + db, "UPDATE users SET must_change_password = 0 WHERE username = 'admin';", + NULL, NULL, NULL)); + TEST_ASSERT_EQUAL_INT(0, db_auth_init()); + TEST_ASSERT_EQUAL_INT(0, db_auth_get_user_by_username("admin", &user)); + TEST_ASSERT_FALSE(user.must_change_password); +} + +void test_auth_init_configured_password_skips_requirement(void) { + sqlite3 *db = get_db_handle(); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec( + db, "DELETE FROM users WHERE username = 'admin';", NULL, NULL, NULL)); + safe_strcpy(g_config.web_password, "operator-password", + sizeof(g_config.web_password), 0); + + TEST_ASSERT_EQUAL_INT(0, db_auth_init()); + + user_t user; + TEST_ASSERT_EQUAL_INT(0, db_auth_get_user_by_username("admin", &user)); + TEST_ASSERT_FALSE(user.must_change_password); + TEST_ASSERT_EQUAL_INT( + 0, db_auth_authenticate("admin", "operator-password", NULL)); +} + /* create_user and get_user_by_username round-trip */ void test_create_and_get_user(void) { int64_t uid = 0; @@ -93,6 +135,33 @@ void test_change_password(void) { TEST_ASSERT_NOT_EQUAL(0, rc); } +void test_required_password_change_rejects_default_and_clears_on_success(void) { + int64_t uid = 0; + TEST_ASSERT_EQUAL_INT(0, db_auth_create_user( + "requiredpw", "oldpass", NULL, USER_ROLE_ADMIN, true, &uid)); + + sqlite3 *db = get_db_handle(); + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2( + db, "UPDATE users SET must_change_password = 1 WHERE id = ?;", + -1, &stmt, NULL)); + sqlite3_bind_int64(stmt, 1, uid); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(stmt)); + sqlite3_finalize(stmt); + + TEST_ASSERT_EQUAL_INT(-3, db_auth_change_password(uid, "admin")); + user_t user; + TEST_ASSERT_EQUAL_INT(0, db_auth_get_user_by_id(uid, &user)); + TEST_ASSERT_TRUE(user.must_change_password); + TEST_ASSERT_EQUAL_INT(0, db_auth_authenticate("requiredpw", "oldpass", NULL)); + + TEST_ASSERT_EQUAL_INT(0, db_auth_change_password(uid, "replacement1")); + TEST_ASSERT_EQUAL_INT(0, db_auth_get_user_by_id(uid, &user)); + TEST_ASSERT_FALSE(user.must_change_password); + TEST_ASSERT_EQUAL_INT( + 0, db_auth_authenticate("requiredpw", "replacement1", NULL)); +} + /* create_session and validate_session */ void test_create_and_validate_session(void) { int64_t uid = 0; @@ -419,10 +488,13 @@ int main(void) { UNITY_BEGIN(); RUN_TEST(test_auth_init_creates_admin); + RUN_TEST(test_auth_init_flags_only_fresh_fallback_admin); + RUN_TEST(test_auth_init_configured_password_skips_requirement); RUN_TEST(test_create_and_get_user); RUN_TEST(test_authenticate_success); RUN_TEST(test_authenticate_wrong_password); RUN_TEST(test_change_password); + RUN_TEST(test_required_password_change_rejects_default_and_clears_on_success); RUN_TEST(test_create_and_validate_session); RUN_TEST(test_validate_session_throttles_tracking_updates); RUN_TEST(test_validate_session_updates_client_context_when_changed); @@ -440,4 +512,3 @@ int main(void) { unlink(TEST_DB_PATH); return result; } - diff --git a/tests/unit/test_httpd_utils.c b/tests/unit/test_httpd_utils.c index b092ca540..db212ca93 100644 --- a/tests/unit/test_httpd_utils.c +++ b/tests/unit/test_httpd_utils.c @@ -34,6 +34,9 @@ #include "utils/strings.h" #include "database/db_auth.h" #include "database/db_core.h" +#include "web/api_handlers_auth.h" +#include "web/api_handlers.h" +#include "web/api_handlers_users.h" /* ---- external globals from lightnvr_lib ---- */ extern config_t g_config; @@ -68,7 +71,8 @@ static void clear_auth_data(void) { int rc = sqlite3_exec(db, "DELETE FROM trusted_devices;" "DELETE FROM sessions;" - "DELETE FROM users WHERE username != 'admin';", + "DELETE FROM users WHERE username != 'admin';" + "UPDATE users SET must_change_password = 0 WHERE username = 'admin';", NULL, NULL, &errmsg); if (rc != SQLITE_OK) { if (errmsg) { @@ -84,11 +88,31 @@ void setUp(void) { /* Ensure auth is enabled by default so we control path in each test */ g_config.web_auth_enabled = true; g_config.demo_mode = false; + g_config.force_mfa_on_login = false; + g_config.web_password[0] = '\0'; g_config.trusted_proxy_cidrs[0] = '\0'; clear_auth_data(); } void tearDown(void) {} +static void set_must_change_password(int64_t user_id, bool required) { + sqlite3 *db = get_db_handle(); + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2( + db, "UPDATE users SET must_change_password = ? WHERE id = ?;", + -1, &stmt, NULL)); + sqlite3_bind_int(stmt, 1, required ? 1 : 0); + sqlite3_bind_int64(stmt, 2, user_id); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(stmt)); + sqlite3_finalize(stmt); +} + +static void add_session_cookie(http_request_t *req, const char *token) { + char cookie[192]; + snprintf(cookie, sizeof(cookie), "session=%s", token); + add_header(req, "Cookie", cookie); +} + /* ================================================================ * httpd_parse_json_body * ================================================================ */ @@ -518,6 +542,176 @@ void test_get_authenticated_user_rejects_api_key_with_spoofed_forwarded_ip_from_ TEST_ASSERT_EQUAL_INT(0, rc); } +void test_required_password_change_restricts_password_auth_but_not_api_keys_or_demo(void) { + int64_t uid = 0; + TEST_ASSERT_EQUAL_INT(0, db_auth_create_user( + "gateadmin", "password123", NULL, USER_ROLE_ADMIN, true, &uid)); + + char api_key[64] = {0}; + TEST_ASSERT_EQUAL_INT(0, db_auth_generate_api_key(uid, api_key, sizeof(api_key))); + set_must_change_password(uid, true); + + char token[128] = {0}; + TEST_ASSERT_EQUAL_INT(0, db_auth_create_session( + uid, "127.0.0.1", "GateTest", 3600, token, sizeof(token))); + + http_request_t req; + http_request_init(&req); + req.method = HTTP_METHOD_GET; + safe_strcpy(req.path, "/api/settings", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + add_session_cookie(&req, token); + + user_t user; + TEST_ASSERT_EQUAL_INT(0, httpd_get_authenticated_user(&req, &user)); + + safe_strcpy(req.path, "/api/auth/verify", sizeof(req.path), 0); + TEST_ASSERT_EQUAL_INT(1, httpd_get_authenticated_user(&req, &user)); + TEST_ASSERT_TRUE(user.must_change_password); + + req.method = HTTP_METHOD_PUT; + snprintf(req.path, sizeof(req.path), "/api/auth/users/%lld/password", + (long long)uid); + TEST_ASSERT_EQUAL_INT(1, httpd_get_authenticated_user(&req, &user)); + + safe_strcpy(req.path, "/api/auth/users/999/password", sizeof(req.path), 0); + TEST_ASSERT_EQUAL_INT(0, httpd_get_authenticated_user(&req, &user)); + + http_request_init(&req); + req.method = HTTP_METHOD_GET; + safe_strcpy(req.path, "/api/settings", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + char bearer[96]; + snprintf(bearer, sizeof(bearer), "Bearer %s", api_key); + add_header(&req, "Authorization", bearer); + TEST_ASSERT_EQUAL_INT(1, httpd_get_authenticated_user(&req, &user)); + TEST_ASSERT_EQUAL_STRING("legacy_api_key", user.authentication_method); + + http_request_init(&req); + req.method = HTTP_METHOD_GET; + safe_strcpy(req.path, "/api/settings", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + add_header(&req, "Authorization", + "Basic Z2F0ZWFkbWluOnBhc3N3b3JkMTIz"); + TEST_ASSERT_EQUAL_INT(0, httpd_get_authenticated_user(&req, &user)); + + g_config.demo_mode = true; + http_request_init(&req); + req.method = HTTP_METHOD_GET; + safe_strcpy(req.path, "/api/settings", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + add_session_cookie(&req, token); + TEST_ASSERT_EQUAL_INT(1, httpd_get_authenticated_user(&req, &user)); +} + +void test_forced_password_handler_verifies_current_password_clears_flag_and_session(void) { + int64_t uid = 0; + TEST_ASSERT_EQUAL_INT(0, db_auth_create_user( + "changeadmin", "password123", NULL, USER_ROLE_ADMIN, true, &uid)); + set_must_change_password(uid, true); + + char token[128] = {0}; + TEST_ASSERT_EQUAL_INT(0, db_auth_create_session( + uid, "127.0.0.1", "GateTest", 3600, token, sizeof(token))); + + const char *wrong_body = + "{\"old_password\":\"wrong-password\",\"new_password\":\"replacement1\"}"; + http_request_t req; + http_request_init(&req); + req.method = HTTP_METHOD_PUT; + snprintf(req.path, sizeof(req.path), "/api/auth/users/%lld/password", + (long long)uid); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + req.body = (void *)wrong_body; + req.body_len = strlen(wrong_body); + add_session_cookie(&req, token); + + http_response_t res; + http_response_init(&res); + handle_users_change_password(&req, &res); + TEST_ASSERT_EQUAL_INT(401, res.status_code); + http_response_free(&res); + + const char *body = + "{\"old_password\":\"password123\",\"new_password\":\"replacement1\"}"; + req.body = (void *)body; + req.body_len = strlen(body); + http_response_init(&res); + handle_users_change_password(&req, &res); + TEST_ASSERT_EQUAL_INT(200, res.status_code); + const char *cleared_cookie = find_response_header(&res, "Set-Cookie"); + TEST_ASSERT_NOT_NULL(cleared_cookie); + TEST_ASSERT_NOT_NULL(strstr(cleared_cookie, "session=;")); + http_response_free(&res); + + user_t user; + TEST_ASSERT_EQUAL_INT(0, db_auth_get_user_by_id(uid, &user)); + TEST_ASSERT_FALSE(user.must_change_password); + TEST_ASSERT_EQUAL_INT(0, db_auth_authenticate( + "changeadmin", "replacement1", NULL)); + TEST_ASSERT_NOT_EQUAL(0, db_auth_validate_session(token, NULL)); +} + +void test_login_reports_required_password_change_before_mfa_and_verify_recovers_it(void) { + int64_t uid = 0; + TEST_ASSERT_EQUAL_INT(0, db_auth_create_user( + "loginadmin", "password123", NULL, USER_ROLE_ADMIN, true, &uid)); + TEST_ASSERT_EQUAL_INT(0, db_auth_set_totp_secret(uid, "JBSWY3DPEHPK3PXP")); + TEST_ASSERT_EQUAL_INT(0, db_auth_enable_totp(uid, true)); + set_must_change_password(uid, true); + g_config.force_mfa_on_login = true; + + const char *body = + "{\"username\":\"loginadmin\",\"password\":\"password123\"}"; + http_request_t req; + http_request_init(&req); + req.method = HTTP_METHOD_POST; + safe_strcpy(req.path, "/api/auth/login", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + safe_strcpy(req.user_agent, "GateTest", sizeof(req.user_agent), 0); + req.body = (void *)body; + req.body_len = strlen(body); + add_header(&req, "Content-Type", "application/json"); + + http_response_t res; + http_response_init(&res); + handle_auth_login(&req, &res); + TEST_ASSERT_EQUAL_INT(200, res.status_code); + cJSON *json = cJSON_Parse((const char *)res.body); + TEST_ASSERT_NOT_NULL(json); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(json, "must_change_password"))); + TEST_ASSERT_NULL(cJSON_GetObjectItem(json, "totp_required")); + cJSON_Delete(json); + + const char *set_cookie = find_response_header(&res, "Set-Cookie"); + TEST_ASSERT_NOT_NULL(set_cookie); + const char *token_start = strstr(set_cookie, "session="); + TEST_ASSERT_NOT_NULL(token_start); + token_start += strlen("session="); + const char *token_end = strchr(token_start, ';'); + TEST_ASSERT_NOT_NULL(token_end); + char token[128] = {0}; + size_t token_len = (size_t)(token_end - token_start); + TEST_ASSERT_TRUE(token_len < sizeof(token)); + memcpy(token, token_start, token_len); + http_response_free(&res); + + http_request_init(&req); + req.method = HTTP_METHOD_GET; + safe_strcpy(req.path, "/api/auth/verify", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + safe_strcpy(req.user_agent, "GateTest", sizeof(req.user_agent), 0); + add_session_cookie(&req, token); + http_response_init(&res); + handle_auth_verify(&req, &res); + TEST_ASSERT_EQUAL_INT(200, res.status_code); + json = cJSON_Parse((const char *)res.body); + TEST_ASSERT_NOT_NULL(json); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(json, "must_change_password"))); + cJSON_Delete(json); + http_response_free(&res); +} + /* ================================================================ * httpd_check_admin_privileges — auth-disabled path * ================================================================ */ @@ -605,6 +799,9 @@ int main(void) { RUN_TEST(test_get_authenticated_user_rejects_session_from_disallowed_ip); RUN_TEST(test_get_authenticated_user_allows_api_key_from_trusted_forwarded_ip); RUN_TEST(test_get_authenticated_user_rejects_api_key_with_spoofed_forwarded_ip_from_untrusted_proxy); + RUN_TEST(test_required_password_change_restricts_password_auth_but_not_api_keys_or_demo); + RUN_TEST(test_forced_password_handler_verifies_current_password_clears_flag_and_session); + RUN_TEST(test_login_reports_required_password_change_before_mfa_and_verify_recovers_it); RUN_TEST(test_check_admin_privileges_auth_disabled_returns_one); RUN_TEST(test_check_admin_privileges_no_auth_returns_zero); RUN_TEST(test_sanitize_attachment_filename_removes_path_and_header_bytes); diff --git a/web/js/components/preact/AuthGate.jsx b/web/js/components/preact/AuthGate.jsx new file mode 100644 index 000000000..ae7111b69 --- /dev/null +++ b/web/js/components/preact/AuthGate.jsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from 'preact/hooks'; + +import { useI18n } from '../../i18n.js'; +import { + clearAuthState, + redirectToLogin, + validateSession, +} from '../../utils/auth-utils.js'; +import { validateForcedPasswordChange } from './forcedPasswordChange.js'; + +export function RequiredPasswordChange({ session }) { + const { t } = useI18n(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmation, setConfirmation] = useState(''); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + document.title = `${t('passwordChangeRequired.title')} - LightNVR`; + }, [t]); + + const handleSubmit = async (event) => { + event.preventDefault(); + const validationError = validateForcedPasswordChange( + currentPassword, + newPassword, + confirmation, + ); + if (validationError) { + setError(t(`passwordChangeRequired.error.${validationError}`)); + return; + } + + setSaving(true); + setError(''); + try { + const response = await fetch(`/api/auth/users/${session.id}/password`, { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + old_password: currentPassword, + new_password: newPassword, + }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.error || t('passwordChangeRequired.error.generic')); + } + + clearAuthState(); + window.location.assign('/login.html?password_changed=true'); + } catch (requestError) { + setError(requestError.message || t('passwordChangeRequired.error.generic')); + setSaving(false); + } + }; + + return ( +
+
+
+ +

{t('passwordChangeRequired.title')}

+

+ {t('passwordChangeRequired.description')} +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setCurrentPassword(event.currentTarget.value)} + disabled={saving} + autoFocus + /> +
+ +
+ + setNewPassword(event.currentTarget.value)} + disabled={saving} + /> +

+ {t('passwordChangeRequired.passwordHelp')} +

+
+ +
+ + setConfirmation(event.currentTarget.value)} + disabled={saving} + /> +
+ + +
+
+
+ ); +} + +export function AuthGate({ children }) { + const { t } = useI18n(); + const [session, setSession] = useState(null); + + useEffect(() => { + let active = true; + validateSession().then((result) => { + if (!active) return; + if (!result.valid) { + clearAuthState(); + redirectToLogin('session_expired'); + return; + } + setSession(result); + }); + return () => { active = false; }; + }, []); + + if (!session) { + return
{t('common.loading')}
; + } + + if (session.must_change_password && !session.demo_mode) { + return ; + } + + return children; +} diff --git a/web/js/components/preact/LoginView.jsx b/web/js/components/preact/LoginView.jsx index d114fb826..c80a87c22 100644 --- a/web/js/components/preact/LoginView.jsx +++ b/web/js/components/preact/LoginView.jsx @@ -112,6 +112,8 @@ export function LoginView() { } } else if (urlParams.has('logout')) { setErrorMessage(t('login.error.loggedOut')); + } else if (urlParams.has('password_changed')) { + setErrorMessage(t('login.passwordChanged')); } else { setErrorMessage(''); } @@ -200,6 +202,11 @@ export function LoginView() { // Successful login (no TOTP required or force MFA verified) console.log('Login successful, proceeding to redirect'); + if (data.must_change_password) { + window.location.href = '/index.html'; + return; + } + // Redirect to the requested page, or the index if none / unsafe. const urlParams = new URLSearchParams(window.location.search); window.location.href = safeRedirectPath(urlParams.get('redirect')); @@ -275,7 +282,8 @@ export function LoginView() { // Check for success messages const isSuccess = ( - errorMessage === t('login.error.loggedOut') + errorMessage === t('login.error.loggedOut') || + errorMessage === t('login.passwordChanged') ); return baseClass + ( diff --git a/web/js/components/preact/SetupWizard.jsx b/web/js/components/preact/SetupWizard.jsx index ddbd8891e..c7ee1b9dd 100644 --- a/web/js/components/preact/SetupWizard.jsx +++ b/web/js/components/preact/SetupWizard.jsx @@ -62,7 +62,7 @@ function WelcomeStep() { {[ '📁 Storage path & size limits', '⚡ Performance limits for your camera count', - '🔒 Admin password reminder', + '🔒 Secure administrator sign-in', ].map(item => (
  • {item}
  • ))} @@ -184,10 +184,6 @@ function CompleteStep({ saved, restartRequired }) { Restart before adding more cameras or expecting the new capacity limit to apply. )} -
    - 🔒 Security reminder: Make sure to change the default admin - password in the Users section if you haven't already. -
    ); } diff --git a/web/js/components/preact/forcedPasswordChange.js b/web/js/components/preact/forcedPasswordChange.js new file mode 100644 index 000000000..f5d72e228 --- /dev/null +++ b/web/js/components/preact/forcedPasswordChange.js @@ -0,0 +1,7 @@ +export function validateForcedPasswordChange(currentPassword, newPassword, confirmation) { + if (!currentPassword) return 'currentRequired'; + if (newPassword === 'admin') return 'defaultPassword'; + if (newPassword.length < 8) return 'tooShort'; + if (newPassword !== confirmation) return 'mismatch'; + return null; +} diff --git a/web/js/pages/hls-page.jsx b/web/js/pages/hls-page.jsx index 3103ae00f..1082d9364 100644 --- a/web/js/pages/hls-page.jsx +++ b/web/js/pages/hls-page.jsx @@ -12,6 +12,7 @@ import { Header } from "../components/preact/Header.jsx"; import { Footer } from "../components/preact/Footer.jsx"; import { setupSessionValidation } from '../utils/auth-utils.js'; import { initI18n } from '../i18n.js'; +import { AuthGate } from '../components/preact/AuthGate.jsx'; /** * Main App component that conditionally renders WebRTCView or LiveView @@ -83,7 +84,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (container) { render( - + + + , container ); diff --git a/web/js/pages/index-page.jsx b/web/js/pages/index-page.jsx index c3e1b8f07..811373a75 100644 --- a/web/js/pages/index-page.jsx +++ b/web/js/pages/index-page.jsx @@ -13,6 +13,7 @@ import { Footer } from "../components/preact/Footer.jsx"; import { ToastContainer } from "../components/preact/ToastContainer.jsx"; import { setupSessionValidation } from '../utils/auth-utils.js'; import { SetupWizard } from '../components/preact/SetupWizard.jsx'; +import { AuthGate } from '../components/preact/AuthGate.jsx'; import { initI18n } from '../i18n.js'; /** @@ -114,10 +115,12 @@ document.addEventListener('DOMContentLoaded', async () => { if (container) { render( -
    - - -