diff --git a/backend/pdm.lock b/backend/pdm.lock index 951bc1f..3a6768f 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -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"}, +] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2b4e136..06d684a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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"]} diff --git a/backend/src/backend/blueprints/members.py b/backend/src/backend/blueprints/members.py new file mode 100644 index 0000000..5cdb594 --- /dev/null +++ b/backend/src/backend/blueprints/members.py @@ -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 {} diff --git a/backend/src/backend/run.py b/backend/src/backend/run.py index 1391154..55b56ba 100644 --- a/backend/src/backend/run.py +++ b/backend/src/backend/run.py @@ -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 diff --git a/backend/src/backend/templates/email.html b/backend/src/backend/templates/email.html index 9aef3dd..4a53a26 100644 --- a/backend/src/backend/templates/email.html +++ b/backend/src/backend/templates/email.html @@ -1,7 +1,7 @@
-