Add one-time password recovery flow

This commit is contained in:
Hamza Ayed
2026-10-03 13:57:12 +03:00
parent 9945e31e7c
commit ceae822302
9 changed files with 427 additions and 2 deletions
+74
View File
@@ -74,6 +74,8 @@ class AuthenticationTests(unittest.TestCase):
("/v1/auth/local-session", "post"),
("/v1/auth/register", "post"),
("/v1/auth/login", "post"),
("/v1/auth/password-reset/request", "post"),
("/v1/auth/password-reset/complete", "post"),
}
missing: list[str] = []
for path, operations in app.openapi()["paths"].items():
@@ -151,6 +153,78 @@ class AuthenticationTests(unittest.TestCase):
)
self.assertEqual(expired.status_code, 401)
def test_password_reset_is_opaque_one_time_and_revokes_existing_sessions(self) -> None:
user_id, old_session = self._register("reset@example.test")
delivered: list[tuple[str, str | None]] = []
def capture_delivery(email: str, token: str | None) -> None:
delivered.append((email, token))
with (
patch("app.main.password_reset_email.smtp_configured", return_value=True),
patch(
"app.main.password_reset_email.send_password_reset_email",
side_effect=capture_delivery,
),
):
known = self.client.post(
"/v1/auth/password-reset/request", json={"email": "Reset@example.test"}
)
unknown = self.client.post(
"/v1/auth/password-reset/request", json={"email": "nobody@example.test"}
)
self.assertEqual(known.status_code, 202, known.text)
self.assertEqual(known.json(), unknown.json())
self.assertEqual(delivered[0][0], "reset@example.test")
self.assertIsNotNone(delivered[0][1])
token = delivered[0][1]
with database._connect() as connection:
stored = connection.execute(
"SELECT token_hash FROM password_reset_tokens WHERE user_id=?", (user_id,)
).fetchone()
self.assertNotEqual(stored["token_hash"], token)
completion = self.client.post(
"/v1/auth/password-reset/complete",
json={"token": token, "new_password": "a completely new passphrase"},
)
self.assertEqual(completion.status_code, 200, completion.text)
old_session_response = self.client.get(
"/v1/auth/me", headers={"Authorization": f"Bearer {old_session}"}
)
self.assertEqual(old_session_response.status_code, 401)
old_password = self.client.post(
"/v1/auth/login",
json={"email": "reset@example.test", "password": "a long secure passphrase"},
)
new_password = self.client.post(
"/v1/auth/login",
json={"email": "reset@example.test", "password": "a completely new passphrase"},
)
self.assertEqual(old_password.status_code, 401)
self.assertEqual(new_password.status_code, 200, new_password.text)
reused = self.client.post(
"/v1/auth/password-reset/complete",
json={"token": token, "new_password": "another new secure passphrase"},
)
self.assertEqual(reused.status_code, 400)
def test_password_reset_tokens_expire(self) -> None:
user_id, _ = self._register("expired-reset@example.test")
token = auth.issue_password_reset("expired-reset@example.test", now=10_000)
self.assertIsNotNone(token)
self.assertFalse(
auth.reset_password(
token or "", "a completely new passphrase", now=10_000 + auth.PASSWORD_RESET_LIFETIME_SECONDS
)
)
with database._connect() as connection:
count = connection.execute(
"SELECT COUNT(*) FROM password_reset_tokens WHERE user_id=?", (user_id,)
).fetchone()[0]
self.assertEqual(count, 0)
def test_conversations_are_isolated_between_accounts(self) -> None:
first_user, first_token = self._register()
_, second_token = self._register()