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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 344 additions and 10 deletions

View file

@ -219,8 +219,8 @@ async def update_user_password(data: UpdateUserPassword, last_login_time: int) -
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds after login."
" Please login again!"
f" {settings.auth_credetials_update_threshold} seconds."
" Please login again or ask a new reset key!"
)
assert data.password == data.password_repeat, "Passwords do not match."
@ -240,7 +240,7 @@ async def update_user_password(data: UpdateUserPassword, last_login_time: int) -
)
user = await get_user(data.user_id)
assert user, "Updated account couldn't be retrieved"
assert user, "Updated account couldn't be retrieved."
return user

View file

@ -194,6 +194,12 @@ class UpdateUserPubkey(BaseModel):
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)

View file

@ -184,6 +184,47 @@
</div>
</q-form>
</q-card-section>
<q-card-section
v-if="authAction === 'reset' && authMethod === 'username-password'"
>
<b> <span v-text="$t('reset_password')"></span> </b><br /><br />
<q-form @submit="reset" class="q-gutter-md">
<q-input
filled
dense
required
:disable="true"
v-model="reset_key"
:label="$t('reset_key') + ' *'"
></q-input>
<q-input
filled
dense
v-model="password"
:label="$t('password') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
filled
dense
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<div>
<q-btn
unelevated
color="primary"
:disable="!password || !passwordRepeat|| !reset_key || (password !== passwordRepeat)"
type="submit"
class="full-width"
:label="$t('reset_password')"
></q-btn>
</div>
</q-form>
</q-card-section>
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
<q-card-section
v-if="authAction === 'register' && authMethod === 'user-id-only'"

View file

@ -84,6 +84,15 @@ include "users/_createWalletDialog.html" %}
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
<q-btn
round
icon="refresh"
size="sm"
color="secondary"
@click="resetPassword(props.row.id)"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"

View file

@ -44,6 +44,7 @@ from ..models import (
CreateUser,
LoginUsernamePassword,
LoginUsr,
ResetUserPassword,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
@ -259,7 +260,50 @@ async def update_pubkey(
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user pubkey."
) from exc
@auth_router.put("/reset")
async def reset_password(data: ResetUserPassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
try:
assert data.reset_key[:10] == "reset_key_", "This is not a reset key."
reset_data_json = decrypt_internal_message(
base64.b64decode(data.reset_key[10:]).decode()
)
assert reset_data_json, "Cannot process reset key."
action, user_id, request_time = json.loads(reset_data_json)
assert action == "reset", "Expected reset action."
assert user_id is not None, "Missing user ID."
assert request_time is not None, "Missing reset time."
user = await get_account(user_id)
assert user, "User not found."
update_pwd = UpdateUserPassword(
user_id=user.id,
username=user.username or "",
password=data.password,
password_repeat=data.password_repeat,
)
user = await update_user_password(update_pwd, request_time)
return _auth_success_response(
username=user.username, user_id=user_id, email=user.email
)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot reset user password."
) from exc
@ -309,7 +353,7 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot init user password."
) from exc

View file

@ -1,3 +1,6 @@
import base64
import json
import time
from http import HTTPStatus
from typing import List
@ -23,7 +26,7 @@ from lnbits.core.models import (
from lnbits.core.services import update_wallet_balance
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import generate_filter_params_openapi
from lnbits.helpers import encrypt_internal_message, generate_filter_params_openapi
from lnbits.settings import EditableSettings, settings
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
@ -75,6 +78,24 @@ async def api_users_delete_user(
) from exc
@users_router.put(
"/user/{user_id}/reset_password", dependencies=[Depends(check_super_user)]
)
async def api_users_reset_password(user_id: str) -> str:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Cannot change superuser password.",
)
reset_data = ["reset", user_id, int(time.time())]
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
reset_key = encrypt_internal_message(reset_data_json)
assert reset_key, "Cannot generate reset key."
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
return f"reset_key_{reset_key_b64}"
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
async def api_users_toggle_admin(user_id: str) -> None:
try:

File diff suppressed because one or more lines are too long

View file

@ -266,5 +266,7 @@ window.localisation.en = {
hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck',
contributors: 'Contributors',
license: 'License'
license: 'License',
reset_key: 'Reset Key',
reset_password: 'Reset Password'
}

View file

@ -81,6 +81,17 @@ window.LNbits = {
}
})
},
reset: function (reset_key, password, password_repeat) {
return axios({
method: 'PUT',
url: '/api/v1/auth/reset',
data: {
reset_key,
password,
password_repeat
}
})
},
login: function (username, password) {
return axios({
method: 'POST',

View file

@ -13,6 +13,7 @@ window.app = Vue.createApp({
authMethod: 'username-password',
usr: '',
username: '',
reset_key: '',
email: '',
password: '',
passwordRepeat: '',
@ -128,6 +129,18 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(e)
}
},
reset: async function () {
try {
await LNbits.api.reset(
this.reset_key,
this.password,
this.passwordRepeat
)
window.location.href = '/wallet'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
login: async function () {
try {
await LNbits.api.login(this.username, this.password)
@ -171,5 +184,11 @@ window.app = Vue.createApp({
if (this.isUserAuthorized) {
window.location.href = '/wallet'
}
this.reset_key = new URLSearchParams(window.location.search).get(
'reset_key'
)
if (this.reset_key) {
this.authAction = 'reset'
}
}
})

View file

@ -218,6 +218,22 @@ window.app = Vue.createApp({
formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000))
},
resetPassword(user_id) {
return LNbits.api
.request('PUT', `/users/api/v1/user/${user_id}/reset_password`)
.then(res => {
this.$q.notify({
type: 'positive',
message: 'generated key for password reset',
icon: null
})
const url = window.location.origin + '?reset_key=' + res.data
this.copyText(url)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createUser() {
LNbits.api
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data)

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!"
)