mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-24 22:58:46 +01:00
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:
parent
3a64cf5245
commit
a4c000d7dc
12 changed files with 344 additions and 10 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!"
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue