feat: add password reset for usermanager (#2688)

* feat: add password reset for usermanager
- add a reset_key to account table
- add ?reset_key= GET arguments to index.html and show reset form if provided
- superuser can generate and copy reset url with key to share
future ideas:
- could add send forgot password email if user fill out email address
* feat: simplify reset key
* test: use reset key
* test: add more tests
* test: reset passwords do not match
* test: `reset_password_auth_threshold_expired`

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
dni ⚡
2024-10-01 10:59:57 +02:00
committed by GitHub
parent 3a64cf5245
commit a4c000d7dc
12 changed files with 344 additions and 10 deletions

View File

@@ -10,6 +10,7 @@ import shortuuid
from httpx import AsyncClient
from lnbits.core.models import AccessTokenPayload, User
from lnbits.core.views.user_api import api_users_reset_password
from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import hex_to_npub, sign_event
@@ -469,8 +470,8 @@ async def test_alan_change_password_auth_threshold_expired(
assert response.status_code == 403, "Treshold expired."
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds after login."
" Please login again!"
" in the first 1 seconds."
" Please login again or ask a new reset key!"
)
@@ -856,3 +857,167 @@ async def test_alan_change_pubkey_auth_threshold_expired(
" in the first 1 seconds after login."
" Please login again!"
)
################################ RESET PASSWORD ################################
@pytest.mark.asyncio
async def test_request_reset_key_ok(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 200, "User created."
access_token = response.json().get("access_token")
assert access_token is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
assert access_token_payload.usr, "User id set."
reset_key = await api_users_reset_password(access_token_payload.usr)
assert reset_key, "Reset key created."
assert reset_key[:10] == "reset_key_", "This is not a reset key."
response = await http_client.put(
"/api/v1/auth/reset",
json={
"reset_key": reset_key,
"password": "secret0000",
"password_repeat": "secret0000",
},
)
assert response.status_code == 200, "Password reset."
access_token = response.json().get("access_token")
assert access_token is not None
response = await http_client.post(
"/api/v1/auth", json={"username": f"u21.{tiny_id}", "password": "secret1234"}
)
assert response.status_code == 401, "Old passord not valid."
assert response.json().get("detail") == "Invalid credentials."
response = await http_client.post(
"/api/v1/auth", json={"username": f"u21.{tiny_id}", "password": "secret0000"}
)
assert response.status_code == 200, "Login new password OK."
access_token = response.json().get("access_token")
assert access_token is not None
@pytest.mark.asyncio
async def test_request_reset_key_user_not_found(http_client: AsyncClient):
user_id = "926abb2ab59a48ebb2485bcceb58d05e"
reset_key = await api_users_reset_password(user_id)
assert reset_key, "Reset key created."
assert reset_key[:10] == "reset_key_", "This is not a reset key."
response = await http_client.put(
"/api/v1/auth/reset",
json={
"reset_key": reset_key,
"password": "secret0000",
"password_repeat": "secret0000",
},
)
assert response.status_code == 403, "User does not exist."
assert response.json().get("detail") == "User not found."
@pytest.mark.asyncio
async def test_reset_username_password_not_allowed(http_client: AsyncClient):
# exclude 'username_password'
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
user_id = "926abb2ab59a48ebb2485bcceb58d05e"
reset_key = await api_users_reset_password(user_id)
assert reset_key, "Reset key created."
response = await http_client.put(
"/api/v1/auth/reset",
json={
"reset_key": reset_key,
"password": "secret0000",
"password_repeat": "secret0000",
},
)
settings.auth_allowed_methods = AuthMethods.all()
assert response.status_code == 401, "Login method not allowed."
assert (
response.json().get("detail") == "Auth by 'Username and Password' not allowed."
)
@pytest.mark.asyncio
async def test_reset_username_passwords_do_not_matcj(
http_client: AsyncClient, user_alan: User
):
reset_key = await api_users_reset_password(user_alan.id)
assert reset_key, "Reset key created."
response = await http_client.put(
"/api/v1/auth/reset",
json={
"reset_key": reset_key,
"password": "secret0000",
"password_repeat": "secret-does-not-mathc",
},
)
assert response.status_code == 403, "Passwords do not match."
assert response.json().get("detail") == "Passwords do not match."
@pytest.mark.asyncio
async def test_reset_username_password_bad_key(http_client: AsyncClient):
response = await http_client.put(
"/api/v1/auth/reset",
json={
"reset_key": "reset_key_xxxxxxxxxxx",
"password": "secret0000",
"password_repeat": "secret0000",
},
)
assert response.status_code == 500, "Bad reset key."
assert response.json().get("detail") == "Cannot reset user password."
@pytest.mark.asyncio
async def test_reset_password_auth_threshold_expired(
user_alan: User, http_client: AsyncClient
):
reset_key = await api_users_reset_password(user_alan.id)
assert reset_key, "Reset key created."
initial_update_threshold = settings.auth_credetials_update_threshold
settings.auth_credetials_update_threshold = 1
time.sleep(1.1)
response = await http_client.put(
"/api/v1/auth/reset",
json={
"reset_key": reset_key,
"password": "secret0000",
"password_repeat": "secret0000",
},
)
settings.auth_credetials_update_threshold = initial_update_threshold
assert response.status_code == 403, "Treshold expired."
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds."
" Please login again or ask a new reset key!"
)