Add one-time password recovery flow
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user