diff --git a/README.md b/README.md
index 408a4db3..52441a60 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 7a01af7a..ffe5d898 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 00000000..9665fd0f
--- /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 9465ea85..ad5f6bd0 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 36c30ed7..f7170bea 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 cf008806..3471b485 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 6a2710e3..ef07856b 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 84227cd9..14f78ebe 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 0ad42bbf..15e8d973 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 e98a6a0e..26d7cadf 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 f315644d..af881825 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 0380e91b..3bf5c8ba 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 dbfd2e5f..b4ffbcf4 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 b32aa6c4..ed53a3a8 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 88b46143..42fc143e 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 839cfa05..8b9ad069 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 9ac9099f..7c035a8d 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 8bdf140c..36da6940 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 b092ca54..db212ca9 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 00000000..ae7111b6
--- /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.description')}
+ {t('passwordChangeRequired.title')}
+