Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,21 @@ 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.
The first run creates the admin account with that password instead. The setting is only
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:

Expand Down
3 changes: 2 additions & 1 deletion config/lightnvr.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions db/migrations/0063_add_must_change_password.sql
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 17 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 9 additions & 4 deletions docs/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 5 additions & 8 deletions docs/TROUBLESHOOTING_WEB_INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions include/database/db_auth.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
17 changes: 16 additions & 1 deletion include/database/db_embedded_migrations.h
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 */
2 changes: 1 addition & 1 deletion src/core/config.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 64 additions & 9 deletions src/database/db_auth.c
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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("********************************************************");

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading