Require password confirmation on account creation (#40)

Both account-creation surfaces now require the password twice:

- Web signup (invite-based self-service): SignupComplete gains a
  password_confirm field with a model_validator backstop server-side,
  plus a client-side match check in SignupPage.tsx for immediate
  feedback -- the client check is the primary UX, the server check is
  defense in depth so the guarantee doesn't rely on the client alone.
- CLI (python -m app.cli create-user): password is now an optional
  positional argument. If omitted, prompts interactively via getpass
  (hidden input) twice, retrying on mismatch -- matching what "entered
  twice and verified" actually means for a human typing blind. Passing
  the password directly as before still works unchanged, for scripted/
  automated provisioning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:48:23 -06:00
co-authored by Claude Sonnet 5
parent dbf9bfa902
commit 2466e76af1
6 changed files with 161 additions and 11 deletions
+78
View File
@@ -0,0 +1,78 @@
import uuid
import pytest
import pytest_asyncio
from sqlalchemy import select
from app.cli import _create_user, _prompt_password
from app.database import async_session_factory
from app.database import engine as _cli_engine
from app.models import User
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
@pytest_asyncio.fixture(autouse=True)
async def _dispose_cli_engine_between_tests():
# _create_user (like the real CLI) uses app.database's module-level
# engine directly, not the db_session fixture's own per-test engine --
# pytest-asyncio gives each test function a fresh event loop by
# default, and a pooled connection opened on a since-closed loop
# produces asyncpg "another operation is in progress" errors if reused
# by a later test. Disposing after every test forces a fresh connection
# next time instead of reusing a stale one.
yield
await _cli_engine.dispose()
async def _get_user(username: str) -> User | None:
async with async_session_factory() as session:
result = await session.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
def test_prompt_password_matches_on_first_try(monkeypatch):
responses = iter(["correct-horse", "correct-horse"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(responses))
assert _prompt_password() == "correct-horse"
def test_prompt_password_retries_on_mismatch(monkeypatch, capsys):
responses = iter(["typo-password", "correct-password", "correct-password", "correct-password"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(responses))
assert _prompt_password() == "correct-password"
assert "didn't match" in capsys.readouterr().out
async def test_create_user_with_explicit_password_succeeds():
username = _unique("alice")
await _create_user(username, f"{username}@example.com", "password123", False)
user = await _get_user(username)
assert user is not None
assert user.is_site_admin is False
async def test_create_user_via_prompted_password(monkeypatch):
responses = iter(["prompted-pass", "prompted-pass"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(responses))
username = _unique("bob")
password = _prompt_password()
await _create_user(username, f"{username}@example.com", password, True)
user = await _get_user(username)
assert user is not None
assert user.is_site_admin is True
async def test_create_user_rejects_short_password():
username = _unique("shortpw")
with pytest.raises(SystemExit):
await _create_user(username, f"{username}@example.com", "short", False)
assert await _get_user(username) is None
+32 -6
View File
@@ -70,7 +70,7 @@ async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
complete = await client.post(
"/api/signup",
json={"token": token, "username": "newperson", "password": "password123"},
json={"token": token, "username": "newperson", "password": "password123", "password_confirm": "password123"},
)
assert complete.status_code == 200, complete.text
assert complete.json()["email"] == "newperson@example.com"
@@ -80,6 +80,32 @@ async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
assert me.json()["username"] == "newperson"
async def test_signup_rejects_mismatched_password_confirmation(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "typo@example.com"})
token = _extract_token(calls[0]["message"].get_content())
complete = await client.post(
"/api/signup",
json={
"token": token,
"username": "typouser",
"password": "password123",
"password_confirm": "password124",
},
)
assert complete.status_code == 422
# The mismatch must not have consumed the invite -- a typo shouldn't
# burn a single-use token.
validate = await client.get(f"/api/signup/validate?token={token}")
assert validate.status_code == 200
async def test_invalid_token_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
@@ -88,7 +114,7 @@ async def test_invalid_token_rejected(client, db_session):
complete = await client.post(
"/api/signup",
json={"token": "not-a-real-token", "username": "someone", "password": "password123"},
json={"token": "not-a-real-token", "username": "someone", "password": "password123", "password_confirm": "password123"},
)
assert complete.status_code == 400
@@ -109,7 +135,7 @@ async def test_expired_token_rejected(client, db_session, monkeypatch):
complete = await client.post(
"/api/signup",
json={"token": token, "username": "late", "password": "password123"},
json={"token": token, "username": "late", "password": "password123", "password_confirm": "password123"},
)
assert complete.status_code == 400
@@ -125,13 +151,13 @@ async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
first = await client.post(
"/api/signup",
json={"token": token, "username": "onceuser", "password": "password123"},
json={"token": token, "username": "onceuser", "password": "password123", "password_confirm": "password123"},
)
assert first.status_code == 200
second = await client.post(
"/api/signup",
json={"token": token, "username": "onceuser2", "password": "password123"},
json={"token": token, "username": "onceuser2", "password": "password123", "password_confirm": "password123"},
)
assert second.status_code == 400
@@ -152,7 +178,7 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
complete = await client.post(
"/api/signup",
json={"token": token, "username": "revokeduser", "password": "password123"},
json={"token": token, "username": "revokeduser", "password": "password123", "password_confirm": "password123"},
)
assert complete.status_code == 400