Feat(API): members CRUD functionalities
- Added tests for members flow (creating/deleting/reset) - Various fixes to tooling
This commit is contained in:
parent
026be1328e
commit
3c78fe9133
10
backend/pdm.lock
generated
10
backend/pdm.lock
generated
@ -554,9 +554,14 @@ dependencies = [
|
|||||||
"h11<1,>=0.9.0",
|
"h11<1,>=0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zxcvbn"
|
||||||
|
version = "4.4.28"
|
||||||
|
summary = ""
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock_version = "4.1"
|
lock_version = "4.1"
|
||||||
content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338ef1b985"
|
content_hash = "sha256:6d55ad044722dfe8cdb3f4b69623fec92653fe73f9ee7ae12717c06912aa3f1e"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
"aiofiles 22.1.0" = [
|
"aiofiles 22.1.0" = [
|
||||||
@ -1027,3 +1032,6 @@ content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338
|
|||||||
{url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
|
{url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
|
||||||
{url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
|
{url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
|
||||||
]
|
]
|
||||||
|
"zxcvbn 4.4.28" = [
|
||||||
|
{url = "https://files.pythonhosted.org/packages/54/67/c6712608c99e7720598e769b8fb09ebd202119785adad0bbce25d330243c/zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"},
|
||||||
|
]
|
||||||
|
@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"quart-schema>=0.14.3",
|
"quart-schema>=0.14.3",
|
||||||
"quart-db[postgresql]>=0.4.1",
|
"quart-db[postgresql]>=0.4.1",
|
||||||
"httpx>=0.23.1",
|
"httpx>=0.23.1",
|
||||||
|
"zxcvbn>=4.4.28",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
||||||
@ -78,4 +79,4 @@ recreate-db-base = "quart --app src/backend/run.py recreate_db"
|
|||||||
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
|
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
|
||||||
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
|
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
|
||||||
|
|
||||||
all = {composite = ["lint", "format", "test"]}
|
all = {composite = ["format", "lint", "test"]}
|
||||||
|
217
backend/src/backend/blueprints/members.py
Normal file
217
backend/src/backend/blueprints/members.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import asyncpg # type: ignore
|
||||||
|
import bcrypt
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
from pydantic import EmailStr
|
||||||
|
from quart import Blueprint, ResponseReturnValue, current_app, g
|
||||||
|
from quart_auth import current_user, login_required
|
||||||
|
from quart_rate_limiter import rate_limit
|
||||||
|
from quart_schema import validate_request
|
||||||
|
from zxcvbn import zxcvbn # type: ignore
|
||||||
|
|
||||||
|
from backend.lib.api_error import APIError
|
||||||
|
from backend.lib.email import send_email
|
||||||
|
from backend.models.member import (
|
||||||
|
insert_member,
|
||||||
|
select_member_by_email,
|
||||||
|
select_member_by_id,
|
||||||
|
update_member_email_verified,
|
||||||
|
update_member_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
blueprint = Blueprint("members", __name__)
|
||||||
|
|
||||||
|
MINIMUM_STRENGTH = 3
|
||||||
|
EMAIL_VERIFICATION_SALT = "email verify"
|
||||||
|
ONE_MONTH = int(timedelta(days=30).total_seconds())
|
||||||
|
FORGOTTEN_PASSWORD_SALT = "forgotten password" # nosec
|
||||||
|
ONE_DAY = int(timedelta(hours=24).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MemberData:
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.post("/members/")
|
||||||
|
@rate_limit(10, timedelta(seconds=10))
|
||||||
|
@validate_request(MemberData)
|
||||||
|
async def register(data: MemberData) -> ResponseReturnValue:
|
||||||
|
"""Create a new Member.
|
||||||
|
This allows a Member to be created.
|
||||||
|
"""
|
||||||
|
strength = zxcvbn(data.password)
|
||||||
|
if strength["score"] < MINIMUM_STRENGTH:
|
||||||
|
raise APIError(400, "WEAK_PASSWORD")
|
||||||
|
|
||||||
|
hashed_password = bcrypt.hashpw(
|
||||||
|
data.password.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(14),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
member = await insert_member(
|
||||||
|
g.connection,
|
||||||
|
data.email,
|
||||||
|
hashed_password.decode(),
|
||||||
|
)
|
||||||
|
except asyncpg.exceptions.UniqueViolationError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
serializer = URLSafeTimedSerializer(
|
||||||
|
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
|
||||||
|
)
|
||||||
|
token = serializer.dumps(member.id)
|
||||||
|
await send_email(
|
||||||
|
member.email,
|
||||||
|
"Welcome",
|
||||||
|
"welcome.html",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {}, 201
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenData:
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.put("/members/email/")
|
||||||
|
@rate_limit(5, timedelta(minutes=1))
|
||||||
|
@validate_request(TokenData)
|
||||||
|
async def verify_email(data: TokenData) -> ResponseReturnValue:
|
||||||
|
"""Call to verify an email.
|
||||||
|
This requires the user to supply a valid token.
|
||||||
|
"""
|
||||||
|
serializer = URLSafeTimedSerializer(
|
||||||
|
current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
member_id = serializer.loads(data.token, max_age=ONE_MONTH)
|
||||||
|
except SignatureExpired:
|
||||||
|
raise APIError(403, "TOKEN_EXPIRED")
|
||||||
|
except BadSignature:
|
||||||
|
raise APIError(400, "TOKEN_INVALID")
|
||||||
|
else:
|
||||||
|
await update_member_email_verified(g.connection, member_id)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PasswordData:
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.put("/members/password/")
|
||||||
|
@rate_limit(5, timedelta(minutes=1))
|
||||||
|
@login_required
|
||||||
|
@validate_request(PasswordData)
|
||||||
|
async def change_password(data: PasswordData) -> ResponseReturnValue:
|
||||||
|
"""Update the members password.
|
||||||
|
This allows the user to update their password.
|
||||||
|
"""
|
||||||
|
strength = zxcvbn(data.new_password)
|
||||||
|
if strength["score"] < MINIMUM_STRENGTH:
|
||||||
|
raise APIError(400, "WEAK_PASSWORD")
|
||||||
|
|
||||||
|
member_id = int(cast(str, current_user.auth_id))
|
||||||
|
member = await select_member_by_id(g.connection, member_id)
|
||||||
|
assert member is not None # nosec
|
||||||
|
passwords_match = bcrypt.checkpw(
|
||||||
|
data.current_password.encode("utf-8"),
|
||||||
|
member.password_hash.encode("utf-8"),
|
||||||
|
)
|
||||||
|
if not passwords_match:
|
||||||
|
raise APIError(401, "INVALID_PASSWORD")
|
||||||
|
|
||||||
|
hashed_password = bcrypt.hashpw(
|
||||||
|
data.new_password.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(14),
|
||||||
|
)
|
||||||
|
await update_member_password(g.connection, member_id, hashed_password.decode())
|
||||||
|
await send_email(
|
||||||
|
member.email,
|
||||||
|
"Password changed",
|
||||||
|
"password_changed.html",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForgottenPasswordData:
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.put("/members/forgotten-password/")
|
||||||
|
@rate_limit(5, timedelta(minutes=1))
|
||||||
|
@validate_request(ForgottenPasswordData)
|
||||||
|
async def forgotten_password(data: ForgottenPasswordData) -> ResponseReturnValue:
|
||||||
|
"""Call to trigger a forgotten password email.
|
||||||
|
This requires a valid member email.
|
||||||
|
"""
|
||||||
|
member = await select_member_by_email(g.connection, data.email)
|
||||||
|
if member is not None:
|
||||||
|
serializer = URLSafeTimedSerializer(
|
||||||
|
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
|
||||||
|
)
|
||||||
|
token = serializer.dumps(member.id)
|
||||||
|
await send_email(
|
||||||
|
member.email,
|
||||||
|
"Forgotten password",
|
||||||
|
"forgotten_password.html",
|
||||||
|
{"token": token},
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResetPasswordData:
|
||||||
|
password: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.put("/members/reset-password/")
|
||||||
|
@rate_limit(5, timedelta(minutes=1))
|
||||||
|
@validate_request(ResetPasswordData)
|
||||||
|
async def reset_password(data: ResetPasswordData) -> ResponseReturnValue:
|
||||||
|
"""Call to reset a password using a token.
|
||||||
|
This requires the user to supply a valid token and a
|
||||||
|
new password.
|
||||||
|
"""
|
||||||
|
serializer = URLSafeTimedSerializer(
|
||||||
|
current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
member_id = serializer.loads(data.token, max_age=ONE_DAY)
|
||||||
|
except SignatureExpired:
|
||||||
|
raise APIError(403, "TOKEN_EXPIRED")
|
||||||
|
except BadSignature:
|
||||||
|
raise APIError(400, "TOKEN_INVALID")
|
||||||
|
else:
|
||||||
|
strength = zxcvbn(data.password)
|
||||||
|
if strength["score"] < MINIMUM_STRENGTH:
|
||||||
|
raise APIError(400, "WEAK_PASSWORD")
|
||||||
|
|
||||||
|
hashed_password = bcrypt.hashpw(
|
||||||
|
data.password.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(14),
|
||||||
|
)
|
||||||
|
await update_member_password(g.connection, member_id, hashed_password.decode())
|
||||||
|
member = await select_member_by_id(
|
||||||
|
g.connection, int(cast(str, current_user.auth_id))
|
||||||
|
)
|
||||||
|
assert member is not None # nosec
|
||||||
|
await send_email(
|
||||||
|
member.email,
|
||||||
|
"Password changed",
|
||||||
|
"password_changed.html",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
return {}
|
@ -20,6 +20,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError
|
|||||||
|
|
||||||
# Each blueprint is a logical collection of features in our web app
|
# Each blueprint is a logical collection of features in our web app
|
||||||
from backend.blueprints.control import blueprint as control_blueprint
|
from backend.blueprints.control import blueprint as control_blueprint
|
||||||
|
from backend.blueprints.members import blueprint as members_blueprint
|
||||||
from backend.blueprints.sessions import blueprint as sessions_blueprint
|
from backend.blueprints.sessions import blueprint as sessions_blueprint
|
||||||
|
|
||||||
# For making sure error responses are in JSON format
|
# For making sure error responses are in JSON format
|
||||||
@ -40,6 +41,7 @@ logging.basicConfig(level=logging.INFO)
|
|||||||
# registers these groups of routes handlers
|
# registers these groups of routes handlers
|
||||||
app.register_blueprint(control_blueprint)
|
app.register_blueprint(control_blueprint)
|
||||||
app.register_blueprint(sessions_blueprint)
|
app.register_blueprint(sessions_blueprint)
|
||||||
|
app.register_blueprint(members_blueprint)
|
||||||
|
|
||||||
|
|
||||||
# rate limiting
|
# rate limiting
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Tozo - email</title>
|
<title>Todo - email</title>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</head>
|
</head>
|
||||||
@ -41,7 +41,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="540">The Tozo team</td>
|
<td align="center" width="540">The Todo team</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
|
9
backend/src/backend/templates/forgotten_password.html
Normal file
9
backend/src/backend/templates/forgotten_password.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% extends "email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
You can use this
|
||||||
|
<a href="{{ config['BASE_URL'] }}/reset-password/{{ token }}/">
|
||||||
|
link
|
||||||
|
</a>
|
||||||
|
to reset your password.
|
||||||
|
{% endblock %}
|
6
backend/src/backend/templates/password_changed.html
Normal file
6
backend/src/backend/templates/password_changed.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
{% extends "email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
Your Todo password has been successfully changed.
|
||||||
|
{% endblock %}
|
12
backend/src/backend/templates/welcome.html
Normal file
12
backend/src/backend/templates/welcome.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends "email.html" %}
|
||||||
|
|
||||||
|
{% block welcome %}
|
||||||
|
Hello and welcome to tozo!
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
Please confirm you signed up by following this
|
||||||
|
<a href="{{ config['BASE_URL'] }}/confirm-email/{{ token }}/">
|
||||||
|
link
|
||||||
|
</a>.
|
||||||
|
{% endblock %}
|
66
backend/tests/blueprints/test_members.py
Normal file
66
backend/tests/blueprints/test_members.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
from backend.blueprints.members import EMAIL_VERIFICATION_SALT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
test_client = app.test_client()
|
||||||
|
data = {
|
||||||
|
"email": "new@todo.minhtrannhat.com",
|
||||||
|
"password": "testPassword2$",
|
||||||
|
}
|
||||||
|
await test_client.post("/members/", json=data)
|
||||||
|
response = await test_client.post("/sessions/", json=data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sending welcome.html to new@todo.minhtrannhat.com" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"time, expected",
|
||||||
|
[("2022-01-01", 403), (None, 200)],
|
||||||
|
)
|
||||||
|
async def test_verify_email(app: Quart, time: str | None, expected: int) -> None:
|
||||||
|
with freeze_time(time):
|
||||||
|
signer = URLSafeTimedSerializer(app.secret_key, salt=EMAIL_VERIFICATION_SALT)
|
||||||
|
token = signer.dumps(1)
|
||||||
|
test_client = app.test_client()
|
||||||
|
response = await test_client.put("/members/email/", json={"token": token})
|
||||||
|
assert response.status_code == expected
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verify_email_invalid_token(app: Quart) -> None:
|
||||||
|
test_client = app.test_client()
|
||||||
|
response = await test_client.put("/members/email/", json={"token": "invalid"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_change_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
test_client = app.test_client()
|
||||||
|
data = {
|
||||||
|
"email": "new_password@todo.minhtrannhat.com",
|
||||||
|
"password": "testPassword2$",
|
||||||
|
}
|
||||||
|
response = await test_client.post("/members/", json=data)
|
||||||
|
async with test_client.authenticated("2"): # type: ignore
|
||||||
|
response = await test_client.put(
|
||||||
|
"/members/password/",
|
||||||
|
json={
|
||||||
|
"currentPassword": data["password"],
|
||||||
|
"newPassword": "testPassword3$",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sending password_changed.html to new@todo.minhtrannhat.com" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forgotten_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
test_client = app.test_client()
|
||||||
|
data = {"email": "member@todo.minhtrannhat.com"}
|
||||||
|
response = await test_client.put("/members/forgotten-password/", json=data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
"Sending forgotten_password.html to member@todo.minhtrannhat.com" in caplog.text
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user