Skip to content

feat: multi-user login#6

Merged
Jamkris merged 1 commit into
mainfrom
feat/multi-user-login
Jul 10, 2026
Merged

feat: multi-user login#6
Jamkris merged 1 commit into
mainfrom
feat/multi-user-login

Conversation

@Jamkris

@Jamkris Jamkris commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

Adds named user accounts on top of the single-password gate.

  • bastion/users.py — user store in data/users.json, PBKDF2-HMAC-SHA256 hashed passwords (pure hash helpers, tested with fixed salt).
  • bastion/secretkey.py — stable server secret (BASTION_SECRET_KEY env or generated data/.secret, 0600) to sign session cookies.
  • auth.sign_session/verify_session + decode_basic.
  • Auth precedence: any users exist → username+password (signed bastion_user cookie); else BASTION_AUTH_PASSWORD; else open. HTTP Basic auth works with user creds too.
  • Settings → Users: list / add / remove / change password. Profile dropdown shows the current user.
  • Login form gains a username field in multi-user mode; rate limiting still applies.
  • Tests isolate the data dir (conftest) so the suite never touches real ./data.

Test plan

  • python -m pytest -q → 145 passed. New: tests/test_users.py, session cases in tests/test_auth.py, multi-user flow in tests/test_web.py.

Notes

  • data/users.json and data/.secret are gitignored and covered by the bastion_data volume.

Summary by cubic

Add multi-user login with named accounts, PBKDF2-HMAC-SHA256 hashed passwords, and signed session cookies. Falls back to the single shared password when no users exist; runs open only when neither users nor a password are configured.

  • New Features

    • Multi-user accounts with username + password; manage under Settings → Users (add, remove, change password).
    • Signed sessions stored in bastion_user using a stable server secret (BASTION_SECRET_KEY or generated at data/.secret).
    • Auth precedence: users exist → multi-user; else BASTION_AUTH_PASSWORD; else open.
    • HTTP Basic auth: username + password in multi-user mode; any username + shared password in single-password mode.
    • Login page shows a username field in multi-user mode; profile menu shows the current user.
    • data/users.json and data/.secret are created under the data dir and are gitignored.
  • Migration

    • No breaking changes; single-password setups keep working until the first user is added (then the shared password is ignored).
    • Set BASTION_SECRET_KEY to share sessions across replicas or control rotation; otherwise a key is generated at data/.secret.

Written for commit d3daee2. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 16 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="bastion/web/templates/base.html">

<violation number="1" location="bastion/web/templates/base.html:71">
P3: `.menu-user` uses `word-break: break-all`, which breaks at any character boundary regardless of whether overflow is imminent. For a username label in a 168px+ dropdown, `overflow-wrap: break-word` (or the legacy `word-break: break-word`) achieves the same overflow protection but keeps words intact unless they actually exceed the container width, making long ASCII usernames more readable.</violation>
</file>

<file name="bastion/secretkey.py">

<violation number="1" location="bastion/secretkey.py:32">
P2: The `.secret` file is created with the process's default umask permissions (via `open(path, "w")`) before being locked down to `0o600` by the subsequent `os.chmod` call. A local attacker or another process on the system could read the secret during this microsecond window. Use `os.open` with `stat.S_IRUSR | stat.S_IWUSR` to create the file with restricted permissions from the instant it hits disk, eliminating the race.</violation>
</file>

<file name="bastion/users.py">

<violation number="1" location="bastion/users.py:48">
P2: A malformed hash entry in `data/users.json` can crash login/basic-auth checks, because `verify_hash()` only catches split errors but not invalid iteration counts or salt hex. Since this file is best-effort runtime JSON, treating those parse failures as `False` would keep a corrupt user record from causing 500 responses.</violation>

<violation number="2" location="bastion/users.py:79">
P3: `count()`, `exists()`, and `usernames()` each re-read and parse the full `users.json` from disk on every call. The auth middleware calls `count()` up to 6 times per request, each triggering I/O. Consider caching the loaded dict (e.g., keep it in a module-level variable updated by `load()` and invalidated by `save()`) to avoid redundant reads.</violation>

<violation number="3" location="bastion/users.py:96">
P2: Concurrent writes in `add()`, `set_password()`, and `remove()` can lose data. Each function reads the full user store (load), modifies the dict, then writes it back (save). If two admin requests arrive at the same time, the second `load()` may read the first's changes but the second `save()` could overwrite the first's write after the first's `os.replace`. Consider a simple file-lock (fcntl.flock or a threading.Lock) around the load+modify+save sequence.</violation>

<violation number="4" location="bastion/users.py:97">
P1: `add()` silently overwrites an existing user's password instead of rejecting the duplicate. An admin who re-submits the add-user form with an existing username (or a script hitting the POST endpoint) will change that user's password without any error or warning. `add()` should either check `exists(username)` and raise `ValueError`, or the web route should call `exists()` first.</violation>
</file>

<file name="bastion/web/templates/views/settings.html">

<violation number="1" location="bastion/web/templates/views/settings.html:220">
P3: The remove button uses `btn-danger` class, but this standalone template's inline CSS doesn't define `.btn-danger`. The button will render as a plain `.btn` (gray/dark) instead of the intended red/danger style, making it visually indistinguishable from non-destructive buttons. Add `.btn-danger { border-color: #6e2831; background: #3d1a1f; color: #ff7b72; }` and `.btn-danger:hover { background: #5a2027; }` to the page's style block to match the pattern used in base.html.</violation>
</file>

<file name="bastion/web/app.py">

<violation number="1" location="bastion/web/app.py:63">
P1: Removed users can keep accessing the dashboard until their existing `bastion_user` cookie expires, because the multi-user cookie path accepts any valid signed username without checking that the username still exists in `users.json`. It would be safer to verify the session username against the current user store before returning authenticated, so `/settings/users/remove` actually revokes access.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread bastion/users.py
if not password:
raise ValueError("password must not be empty")
users = load()
users[username] = hash_password(password)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: add() silently overwrites an existing user's password instead of rejecting the duplicate. An admin who re-submits the add-user form with an existing username (or a script hitting the POST endpoint) will change that user's password without any error or warning. add() should either check exists(username) and raise ValueError, or the web route should call exists() first.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/users.py, line 97:

<comment>`add()` silently overwrites an existing user's password instead of rejecting the duplicate. An admin who re-submits the add-user form with an existing username (or a script hitting the POST endpoint) will change that user's password without any error or warning. `add()` should either check `exists(username)` and raise `ValueError`, or the web route should call `exists()` first.</comment>

<file context>
@@ -0,0 +1,123 @@
+    if not password:
+        raise ValueError("password must not be empty")
+    users = load()
+    users[username] = hash_password(password)
+    save(users)
+    log.info("user added: %s", username)
</file context>

Comment thread bastion/web/app.py
"""Multi-user mode (any user exists) takes precedence; otherwise fall back
to the single shared password; otherwise the dashboard is open."""
if users.count() > 0:
if auth.verify_session(request.cookies.get(auth.USER_COOKIE), secretkey.get()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Removed users can keep accessing the dashboard until their existing bastion_user cookie expires, because the multi-user cookie path accepts any valid signed username without checking that the username still exists in users.json. It would be safer to verify the session username against the current user store before returning authenticated, so /settings/users/remove actually revokes access.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/web/app.py, line 63:

<comment>Removed users can keep accessing the dashboard until their existing `bastion_user` cookie expires, because the multi-user cookie path accepts any valid signed username without checking that the username still exists in `users.json`. It would be safer to verify the session username against the current user store before returning authenticated, so `/settings/users/remove` actually revokes access.</comment>

<file context>
@@ -52,8 +52,30 @@ async def _lifespan(app: FastAPI):
+    """Multi-user mode (any user exists) takes precedence; otherwise fall back
+    to the single shared password; otherwise the dashboard is open."""
+    if users.count() > 0:
+        if auth.verify_session(request.cookies.get(auth.USER_COOKIE), secretkey.get()):
+            return True
+        creds = auth.decode_basic(request.headers.get("authorization"))
</file context>

Comment thread bastion/secretkey.py
return f.read().strip()
os.makedirs(settings.data_dir, exist_ok=True)
key = secrets.token_hex(32)
with open(path, "w", encoding="utf-8") as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The .secret file is created with the process's default umask permissions (via open(path, "w")) before being locked down to 0o600 by the subsequent os.chmod call. A local attacker or another process on the system could read the secret during this microsecond window. Use os.open with stat.S_IRUSR | stat.S_IWUSR to create the file with restricted permissions from the instant it hits disk, eliminating the race.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/secretkey.py, line 32:

<comment>The `.secret` file is created with the process's default umask permissions (via `open(path, "w")`) before being locked down to `0o600` by the subsequent `os.chmod` call. A local attacker or another process on the system could read the secret during this microsecond window. Use `os.open` with `stat.S_IRUSR | stat.S_IWUSR` to create the file with restricted permissions from the instant it hits disk, eliminating the race.</comment>

<file context>
@@ -0,0 +1,41 @@
+                return f.read().strip()
+        os.makedirs(settings.data_dir, exist_ok=True)
+        key = secrets.token_hex(32)
+        with open(path, "w", encoding="utf-8") as f:
+            f.write(key)
+        os.chmod(path, 0o600)
</file context>

Comment thread bastion/users.py
raise ValueError(f"invalid username: {username!r}")
if not password:
raise ValueError("password must not be empty")
users = load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent writes in add(), set_password(), and remove() can lose data. Each function reads the full user store (load), modifies the dict, then writes it back (save). If two admin requests arrive at the same time, the second load() may read the first's changes but the second save() could overwrite the first's write after the first's os.replace. Consider a simple file-lock (fcntl.flock or a threading.Lock) around the load+modify+save sequence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/users.py, line 96:

<comment>Concurrent writes in `add()`, `set_password()`, and `remove()` can lose data. Each function reads the full user store (load), modifies the dict, then writes it back (save). If two admin requests arrive at the same time, the second `load()` may read the first's changes but the second `save()` could overwrite the first's write after the first's `os.replace`. Consider a simple file-lock (fcntl.flock or a threading.Lock) around the load+modify+save sequence.</comment>

<file context>
@@ -0,0 +1,123 @@
+        raise ValueError(f"invalid username: {username!r}")
+    if not password:
+        raise ValueError("password must not be empty")
+    users = load()
+    users[username] = hash_password(password)
+    save(users)
</file context>

Comment thread bastion/users.py
Comment on lines +48 to +49
candidate = hash_password(password, salt=salt_hex, iterations=int(iters))
return hmac.compare_digest(candidate, stored)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A malformed hash entry in data/users.json can crash login/basic-auth checks, because verify_hash() only catches split errors but not invalid iteration counts or salt hex. Since this file is best-effort runtime JSON, treating those parse failures as False would keep a corrupt user record from causing 500 responses.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/users.py, line 48:

<comment>A malformed hash entry in `data/users.json` can crash login/basic-auth checks, because `verify_hash()` only catches split errors but not invalid iteration counts or salt hex. Since this file is best-effort runtime JSON, treating those parse failures as `False` would keep a corrupt user record from causing 500 responses.</comment>

<file context>
@@ -0,0 +1,123 @@
+        return False
+    if algo != "pbkdf2_sha256":
+        return False
+    candidate = hash_password(password, salt=salt_hex, iterations=int(iters))
+    return hmac.compare_digest(candidate, stored)
+
</file context>
Suggested change
candidate = hash_password(password, salt=salt_hex, iterations=int(iters))
return hmac.compare_digest(candidate, stored)
try:
candidate = hash_password(password, salt=salt_hex, iterations=int(iters))
except ValueError:
return False
return hmac.compare_digest(candidate, stored)

.profile .menu .ico { width: 16px; text-align: center; color: #7d8590; }
.profile .menu-user {
padding: 6px 10px 8px; margin-bottom: 4px; border-bottom: 1px solid #21262d;
color: #7d8590; font-size: 12px; word-break: break-all;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: .menu-user uses word-break: break-all, which breaks at any character boundary regardless of whether overflow is imminent. For a username label in a 168px+ dropdown, overflow-wrap: break-word (or the legacy word-break: break-word) achieves the same overflow protection but keeps words intact unless they actually exceed the container width, making long ASCII usernames more readable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/web/templates/base.html, line 71:

<comment>`.menu-user` uses `word-break: break-all`, which breaks at any character boundary regardless of whether overflow is imminent. For a username label in a 168px+ dropdown, `overflow-wrap: break-word` (or the legacy `word-break: break-word`) achieves the same overflow protection but keeps words intact unless they actually exceed the container width, making long ASCII usernames more readable.</comment>

<file context>
@@ -66,6 +66,10 @@
     .profile .menu .ico { width: 16px; text-align: center; color: #7d8590; }
+    .profile .menu-user {
+      padding: 6px 10px 8px; margin-bottom: 4px; border-bottom: 1px solid #21262d;
+      color: #7d8590; font-size: 12px; word-break: break-all;
+    }
 
</file context>
Suggested change
color: #7d8590; font-size: 12px; word-break: break-all;
color: #7d8590; font-size: 12px; overflow-wrap: break-word;

Comment thread bastion/users.py
os.replace(tmp, path)


def count() -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: count(), exists(), and usernames() each re-read and parse the full users.json from disk on every call. The auth middleware calls count() up to 6 times per request, each triggering I/O. Consider caching the loaded dict (e.g., keep it in a module-level variable updated by load() and invalidated by save()) to avoid redundant reads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/users.py, line 79:

<comment>`count()`, `exists()`, and `usernames()` each re-read and parse the full `users.json` from disk on every call. The auth middleware calls `count()` up to 6 times per request, each triggering I/O. Consider caching the loaded dict (e.g., keep it in a module-level variable updated by `load()` and invalidated by `save()`) to avoid redundant reads.</comment>

<file context>
@@ -0,0 +1,123 @@
+    os.replace(tmp, path)
+
+
+def count() -> int:
+    return len(load())
+
</file context>

<form method="post" action="/settings/users/remove" style="display:inline"
onsubmit="return confirm('{{ t('remove') }} {{ u }}?')">
<input type="hidden" name="username" value="{{ u }}">
<button type="submit" class="btn btn-danger">{{ t("remove") }}</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The remove button uses btn-danger class, but this standalone template's inline CSS doesn't define .btn-danger. The button will render as a plain .btn (gray/dark) instead of the intended red/danger style, making it visually indistinguishable from non-destructive buttons. Add .btn-danger { border-color: #6e2831; background: #3d1a1f; color: #ff7b72; } and .btn-danger:hover { background: #5a2027; } to the page's style block to match the pattern used in base.html.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At bastion/web/templates/views/settings.html, line 220:

<comment>The remove button uses `btn-danger` class, but this standalone template's inline CSS doesn't define `.btn-danger`. The button will render as a plain `.btn` (gray/dark) instead of the intended red/danger style, making it visually indistinguishable from non-destructive buttons. Add `.btn-danger { border-color: #6e2831; background: #3d1a1f; color: #ff7b72; }` and `.btn-danger:hover { background: #5a2027; }` to the page's style block to match the pattern used in base.html.</comment>

<file context>
@@ -200,6 +201,69 @@ <h2>{{ t("sec_security") }}</h2>
+                    <form method="post" action="/settings/users/remove" style="display:inline"
+                          onsubmit="return confirm('{{ t('remove') }} {{ u }}?')">
+                      <input type="hidden" name="username" value="{{ u }}">
+                      <button type="submit" class="btn btn-danger">{{ t("remove") }}</button>
+                    </form>
+                  </td>
</file context>

@Jamkris
Jamkris force-pushed the feat/multi-user-login branch from dd530a7 to d3daee2 Compare July 10, 2026 02:32
@Jamkris
Jamkris merged commit 12dd9cb into main Jul 10, 2026
3 checks passed
@Jamkris
Jamkris deleted the feat/multi-user-login branch July 10, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant