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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zxcvbn"
|
||||
version = "4.4.28"
|
||||
summary = ""
|
||||
|
||||
[metadata]
|
||||
lock_version = "4.1"
|
||||
content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338ef1b985"
|
||||
content_hash = "sha256:6d55ad044722dfe8cdb3f4b69623fec92653fe73f9ee7ae12717c06912aa3f1e"
|
||||
|
||||
[metadata.files]
|
||||
"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/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-db[postgresql]>=0.4.1",
|
||||
"httpx>=0.23.1",
|
||||
"zxcvbn>=4.4.28",
|
||||
]
|
||||
[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"}
|
||||
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
|
||||
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
|
||||
|
||||
# For making sure error responses are in JSON format
|
||||
@ -40,6 +41,7 @@ logging.basicConfig(level=logging.INFO)
|
||||
# registers these groups of routes handlers
|
||||
app.register_blueprint(control_blueprint)
|
||||
app.register_blueprint(sessions_blueprint)
|
||||
app.register_blueprint(members_blueprint)
|
||||
|
||||
|
||||
# rate limiting
|
||||
|
@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tozo - email</title>
|
||||
<title>Todo - email</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
@ -41,7 +41,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="540">The Tozo team</td>
|
||||
<td align="center" width="540">The Todo team</td>
|
||||
</tr>
|
||||
</table>
|
||||
</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