Feat(API): members CRUD functionalities

- Added tests for members flow (creating/deleting/reset)
- Various fixes to tooling
This commit is contained in:
minhtrannhat 2022-12-26 14:56:54 -05:00 committed by Minh Tran Nhat
parent 026be1328e
commit 3c78fe9133
9 changed files with 325 additions and 4 deletions

10
backend/pdm.lock generated
View File

@ -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"},
]

View File

@ -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"]}

View 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 {}

View File

@ -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

View File

@ -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>

View 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 %}

View File

@ -0,0 +1,6 @@
{% extends "email.html" %}
{% block content %}
Your Todo password has been successfully changed.
{% endblock %}

View 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 %}

View 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
)