diff --git a/.env.example b/.env.example index a2f0b0e..cce1231 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,16 @@ # salt for JWTs export SECRET_KEY='' + # github export GITHUB_TOKEN='' export GITHUB_USERNAME='schoolsyst-issues-manager' + # database export ARANGODB_HOST="http://localhost:8529" export ARANGODB_USERNAME="root" export ARANGO_ROOT_PASSWORD="openSesame" + +# send mails (test) +export GMAIL_USERNAME="" +export GMAIL_PASSWORD="" diff --git a/.travis.yml b/.travis.yml index 6ebffb5..06210a7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,8 @@ before_install: - echo -ne "ARANGO_ROOT_PASSWORD=\"openSesame\"\n" >> .env - echo -ne "GITHUB_USERNAME=\"$GITHUB_USERNAME\"\n" >> .env - echo -ne "GITHUB_TOKEN=\"$GITHUB_TOKEN\"\n" >> .env + - echo -ne "GMAIL_USERNAME=\"$GMAIL_USERNAME\"\n" >> .env + - echo -ne "GMAIL_PASSWORD=\"$GMAIL_PASSWORD\"\n" >> .env - pip install 'poetry>=1.0' - docker run -d -p 8529:8529 -e ARANGO_ROOT_PASSWORD=openSesame arangodb/arangodb:3.6.5 diff --git a/poetry.lock b/poetry.lock index ad108e7..dee0510 100644 --- a/poetry.lock +++ b/poetry.lock @@ -140,6 +140,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "chevron" +version = "0.13.1" +description = "Mustache templating language renderer" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "click" version = "7.1.2" @@ -888,8 +896,8 @@ full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "p [[package]] name = "stdlib-list" -version = "0.7.0" -description = "A list of Python Standard Libraries (2.6-7, 3.2-8)." +version = "0.8.0" +description = "A list of Python Standard Libraries (2.6-7, 3.2-9)." category = "dev" optional = false python-versions = "*" @@ -988,7 +996,7 @@ python-versions = "*" [[package]] name = "virtualenv" -version = "20.1.0" +version = "20.2.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1020,6 +1028,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "yagmail" +version = "0.11.224" +description = "Yet Another GMAIL client" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +all = ["keyring"] + [[package]] name = "zxcvbn" version = "4.4.28" @@ -1030,8 +1049,8 @@ python-versions = "*" [metadata] lock-version = "1.1" -python-versions = "^3.8" -content-hash = "1f318bc4c8d62b545037d45083cc00373624eed3ded4f31ed9baf6b8570c9a4c" +python-versions = "^3.9" +content-hash = "72e61295736fb0de54e507333065a1804da692c4895134f94578804fbd74b4df" [metadata.files] aiofiles = [ @@ -1133,6 +1152,10 @@ chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] +chevron = [ + {file = "chevron-0.13.1-py3-none-any.whl", hash = "sha256:95b0a055ef0ada5eb061d60be64a7f70670b53372ccd221d1b88adf1c41a9094"}, + {file = "chevron-0.13.1.tar.gz", hash = "sha256:f95054a8b303268ebf3efd6bdfc8c1b428d3fc92327913b4e236d062ec61c989"}, +] click = [ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, @@ -1631,8 +1654,8 @@ starlette = [ {file = "starlette-0.13.6.tar.gz", hash = "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc"}, ] stdlib-list = [ - {file = "stdlib-list-0.7.0.tar.gz", hash = "sha256:66c1c1724a12667cdb35be9f43181c3e6646c194e631efaaa93c1f2c2c7a1f7f"}, - {file = "stdlib_list-0.7.0-py3-none-any.whl", hash = "sha256:0ed79a0badf4f666aad046cde364ccac68ca1438a211ec74b0153e0eb5642a3e"}, + {file = "stdlib-list-0.8.0.tar.gz", hash = "sha256:a1e503719720d71e2ed70ed809b385c60cd3fb555ba7ec046b96360d30b16d9f"}, + {file = "stdlib_list-0.8.0-py3-none-any.whl", hash = "sha256:2ae0712a55b68f3fbbc9e58d6fa1b646a062188f49745b495f94d3310a9fdd3e"}, ] text-unidecode = [ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, @@ -1712,8 +1735,8 @@ uvloop = [ {file = "uvloop-0.14.0.tar.gz", hash = "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e"}, ] virtualenv = [ - {file = "virtualenv-20.1.0-py2.py3-none-any.whl", hash = "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2"}, - {file = "virtualenv-20.1.0.tar.gz", hash = "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"}, + {file = "virtualenv-20.2.0-py2.py3-none-any.whl", hash = "sha256:6af42359fbb33a6c7eab4d3246524b96fd9d8e07e7141b7a65998f96e28b2c57"}, + {file = "virtualenv-20.2.0.tar.gz", hash = "sha256:fd4147c5ba3f694e2e4fc3c767407dc2226899623bb9b49c2f15637c2ee335b3"}, ] websockets = [ {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, @@ -1742,6 +1765,10 @@ websockets = [ wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, ] +yagmail = [ + {file = "yagmail-0.11.224-py2.py3-none-any.whl", hash = "sha256:a055d78eca8846bee8329b1bd337599b0b09462d4d24471fba44db30dbfec03f"}, + {file = "yagmail-0.11.224.tar.gz", hash = "sha256:ca5ff7eee3c190eedd7cbdf05bff69c5c0ae357aef64f89dcc42e4872c6ebe7c"}, +] zxcvbn = [ {file = "zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"}, ] diff --git a/pyproject.toml b/pyproject.toml index 5fa57b8..4944ee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ nanoid = "^2.0.0" python-slugify = "^4.0.1" fastapi_utils = "^0.2.1" fastapi-etag = "^0.2.2" +chevron = "^0.13.1" +yagmail = "^0.11.224" [tool.poetry.dev-dependencies] black = "^19.10b0" diff --git a/requirements.txt b/requirements.txt index d9c8b46..6166412 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,6 +56,9 @@ cffi==1.14.2 \ chardet==3.0.4 \ --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae +chevron==0.13.1 \ + --hash=sha256:95b0a055ef0ada5eb061d60be64a7f70670b53372ccd221d1b88adf1c41a9094 \ + --hash=sha256:f95054a8b303268ebf3efd6bdfc8c1b428d3fc92327913b4e236d062ec61c989 click==7.1.2 \ --hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc \ --hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a @@ -360,5 +363,8 @@ websockets==8.1 \ --hash=sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36 \ --hash=sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b \ --hash=sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f +yagmail==0.11.224 \ + --hash=sha256:a055d78eca8846bee8329b1bd337599b0b09462d4d24471fba44db30dbfec03f \ + --hash=sha256:ca5ff7eee3c190eedd7cbdf05bff69c5c0ae357aef64f89dcc42e4872c6ebe7c zxcvbn==4.4.28 \ --hash=sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1 diff --git a/requirements_dev.txt b/requirements_dev.txt index 4a2828a..e309dc7 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -76,6 +76,9 @@ cfgv==3.2.0 \ chardet==3.0.4 \ --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae +chevron==0.13.1 \ + --hash=sha256:95b0a055ef0ada5eb061d60be64a7f70670b53372ccd221d1b88adf1c41a9094 \ + --hash=sha256:f95054a8b303268ebf3efd6bdfc8c1b428d3fc92327913b4e236d062ec61c989 click==7.1.2 \ --hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc \ --hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a @@ -276,9 +279,9 @@ markupsafe==1.1.1 \ mccabe==0.6.1 \ --hash=sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42 \ --hash=sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f -more-itertools==8.4.0 \ - --hash=sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5 \ - --hash=sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2 +more-itertools==8.5.0 \ + --hash=sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20 \ + --hash=sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c nanoid==2.0.0 \ --hash=sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb \ --hash=sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68 @@ -570,5 +573,8 @@ websockets==8.1 \ --hash=sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36 \ --hash=sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b \ --hash=sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f +yagmail==0.11.224 \ + --hash=sha256:a055d78eca8846bee8329b1bd337599b0b09462d4d24471fba44db30dbfec03f \ + --hash=sha256:ca5ff7eee3c190eedd7cbdf05bff69c5c0ae357aef64f89dcc42e4872c6ebe7c zxcvbn==4.4.28 \ --hash=sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1 diff --git a/schoolsyst_api/accounts/email_confirmation.py b/schoolsyst_api/accounts/email_confirmation.py index 8d2c40c..2a13c0b 100644 --- a/schoolsyst_api/accounts/email_confirmation.py +++ b/schoolsyst_api/accounts/email_confirmation.py @@ -1,102 +1,43 @@ import os -from datetime import datetime, timedelta +from datetime import timedelta from arango.database import StandardDatabase from dotenv import load_dotenv -from fastapi import BackgroundTasks, Depends, HTTPException, status -from jose import jwt -from pydantic import BaseModel +from fastapi import BackgroundTasks, Depends, status from schoolsyst_api import database -from schoolsyst_api.accounts import ( - JWT_SIGN_ALGORITHM, - create_jwt_token, - router, - verify_jwt_token, -) -from schoolsyst_api.accounts.models import User, UserKey -from schoolsyst_api.accounts.password_reset import VALID_FOR +from schoolsyst_api.accounts import router +from schoolsyst_api.accounts.models import User from schoolsyst_api.accounts.users import get_current_user +from schoolsyst_api.email_confirmed_action import EmailConfirmedAction load_dotenv(".env") SECRET_KEY = os.getenv("SECRET_KEY") -TOKEN_VALID_FOR = timedelta(hours=24) -JWT_SUB_FORMAT = "email-confirmation:{}" - - -def send_email_confirmation_email( - to_email: str, to_username: str, email_confirm_request_token: str -) -> bool: - email = f"""\ -From: schoolsyst password reset system -To: {to_username} <{to_email}> -Subject: Confirm your email address - -Go to https://app.schoolsyst.com/email-confirm/{email_confirm_request_token} to confirm your email address. -If you didn't request an email confirmation or don't know what schoolsyst is, -this means that someone tried to register an account and/or confirm an email address using -yours instead of theirs. Just ignore this and your email address won't be confirmed. -If you have any reason to think that this person has access to your mailbox, you may change your -email account's password. -""" - print("[fake] sending email:") - print("---") - print(email) - print("---") - # host = SMTP("email.schoolsyst.com", SMTP_SSL_PORT, "localhost") - # host.sendmail( - # from_addr="reset-password@schoolsyst.com", - # to_addrs=[to_email], - # msg=email, - # ) - return True - - -class EmailConfirmationRequest(BaseModel): - created_at: datetime - token: str - emitted_by_key: UserKey +VALID_FOR = timedelta(hours=24) - -def create_email_confirmation_request_token( - username: str, request_valid_for: timedelta -) -> str: - return jwt.encode( - { - "sub": JWT_SUB_FORMAT.format(username), - "exp": datetime.utcnow() + request_valid_for, - }, - SECRET_KEY, - algorithm=JWT_SIGN_ALGORITHM, - ) +helper = EmailConfirmedAction( + name="email_confirmation", + callback_url="https://app.schoolsyst.com/confirm_email/{}/", + token_valid_for=VALID_FOR, + email_subject="schoolsyst: Confirmation d'email - {}", +) @router.post( - "/users/email-confirmation-request", + "/email_confirmation/request", summary="Request an email confirmation", description=f""" Sends an email to the logged-in user's email address, and creates a `EmailConfirmationRequest` with a temporary token, The email can then be confirmed with `POST /users/email-confirmation/`. -The token is considered expired {VALID_FOR.total_seconds() / 3600} hours after creation, -and the `EmailConfirmationRequest` is destroyed once an associated -`POST /users/email-confirmation` is made. - -In other words, the request is valid for up to {VALID_FOR.total_seconds() / 3600} hours, -and can only be used once. +The token is considered expired {VALID_FOR.total_seconds() / 3600} hours after creation. """.strip(), status_code=status.HTTP_202_ACCEPTED, ) async def post_users_password_reset_request( - tasks: BackgroundTasks, - user: User = Depends(get_current_user), - db: StandardDatabase = Depends(database.get), + tasks: BackgroundTasks, user: User = Depends(get_current_user), ): - # create a token - token = create_jwt_token(JWT_SUB_FORMAT, user.username, VALID_FOR) - - # send an email - tasks.add_task(send_email_confirmation_email, user.email, user.username, token) + helper.send_request(current_user=user, tasks=tasks) return @@ -108,7 +49,7 @@ async def post_users_password_reset_request( @router.post( - "/users/email-confirmation", + "/email_confirmation", summary="Confirm an email", responses=post_users_email_confirmation_responses, ) @@ -118,18 +59,14 @@ async def post_users_password_reset( db: StandardDatabase = Depends(database.get), ): """ - Confirms the user's email address given a `request_token`. + Confirms the user's email address given a `token`. - The `request_token` is a token sent to the user's email address as a link + The `token` is a token sent to the user's email address as a link (something like `https://app.schoolsyst.com/email-confirmation/{request_token}`) after performing a `POST /users/email-confirmation-request`. """ # verify the token - if not verify_jwt_token(token, JWT_SUB_FORMAT, user.username): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail=post_users_email_confirmation_responses[403]["description"], - ) + helper.handle_token_verification(token, user) # confirm the email db.collection("users").update({"_key": user.key, "email_is_confirmed": True}) return {} diff --git a/schoolsyst_api/accounts/password_reset.py b/schoolsyst_api/accounts/password_reset.py index 8327aec..e023386 100644 --- a/schoolsyst_api/accounts/password_reset.py +++ b/schoolsyst_api/accounts/password_reset.py @@ -1,12 +1,12 @@ import os -from datetime import datetime, timedelta +from datetime import timedelta from arango.database import StandardDatabase from dotenv import load_dotenv from fastapi import BackgroundTasks, Depends, HTTPException, status from pydantic import BaseModel from schoolsyst_api import database -from schoolsyst_api.accounts import create_jwt_token, router, verify_jwt_token +from schoolsyst_api.accounts import router from schoolsyst_api.accounts.auth import ( get_password_analysis, hash_password, @@ -14,42 +14,18 @@ ) from schoolsyst_api.accounts.models import User from schoolsyst_api.accounts.users import get_current_confirmed_user -from schoolsyst_api.models import UserKey +from schoolsyst_api.email_confirmed_action import EmailConfirmedAction load_dotenv(".env") SECRET_KEY = os.getenv("SECRET_KEY") VALID_FOR = timedelta(minutes=30) -JWT_SUB_FORMAT = "password-reset:{}" - -class PasswordResetRequest(BaseModel): - created_at: datetime - token: str - emitted_by_key: UserKey - - -def send_password_reset_email( - to_email: str, to_username: str, password_reset_request_token: str -) -> bool: - email = f"""\ -From: schoolsyst password reset system -To: {to_username} <{to_email}> -Subject: Reset your schoolsyst password - -Go to https://app.schoolsyst.com/reset-password/{password_reset_request_token} to reset it. -If you didn't request a password reset, just ignore this, and your password won't be modified. -""" - print("[fake] sending email:") - print("---") - print(email) - print("---") - # host = SMTP("email.schoolsyst.com", SMTP_SSL_PORT, "localhost") - # host.sendmail( - # from_addr="reset-password@schoolsyst.com", - # to_addrs=[to_email], - # msg=email, - # ) - return True +helper = EmailConfirmedAction( + name="password_reset", + callback_url="/reset-password", + token_valid_for=VALID_FOR, + email_subject="schoolsyst: Réinitialisation de mot de passe - {}", +) @router.post( @@ -72,11 +48,7 @@ def send_password_reset_email( async def post_users_password_reset_request( tasks: BackgroundTasks, user: User = Depends(get_current_confirmed_user), ): - # create a request - token = create_jwt_token(JWT_SUB_FORMAT, user.username, VALID_FOR) - - # send an email - tasks.add_task(send_password_reset_email, user.email, user.username, token) + helper.send_request(current_user=user, tasks=tasks) return @@ -110,6 +82,9 @@ async def post_users_password_reset( (something like `https://app.schoolsyst.com/password-reset/{request_token}`) after performing a `POST /users/password-reset-request`. """ + helper.handle_token_verification( + input_token=change_data.request_token, current_user=user + ) analysis = get_password_analysis( change_data.new_password, user.email, user.username ) @@ -119,12 +94,6 @@ async def post_users_password_reset( detail="This password is not strong enough", headers={"X-See": "GET /password_analysis/"}, ) - # validate the token - if not verify_jwt_token(change_data.request_token, JWT_SUB_FORMAT, user.username): - return HTTPException( - status.HTTP_403_FORBIDDEN, - detail=post_users_password_reset_responses[403]["description"], - ) # change the password db.collection("users").update( {"_key": user.key, "password_hash": hash_password(change_data.new_password)} diff --git a/schoolsyst_api/email_confirmed_action.py b/schoolsyst_api/email_confirmed_action.py new file mode 100644 index 0000000..00ebf17 --- /dev/null +++ b/schoolsyst_api/email_confirmed_action.py @@ -0,0 +1,124 @@ +from datetime import timedelta +from os import getenv +from pathlib import Path +from typing import Callable, Literal, Union + +import chevron +import yagmail +from fastapi import BackgroundTasks, HTTPException, status +from schoolsyst_api.accounts import create_jwt_token, verify_jwt_token +from schoolsyst_api.accounts.models import User + + +class EmailConfirmedAction: + name: str + callback_url: str + token_valid_for: timedelta + email_subject: str + + def __init__( + self, + name: str, + callback_url: str, + token_valid_for: timedelta, + email_subject: str, + ) -> None: + """ + `callback_url` is used as the link to click on in the email. + All occurences of `{}` will be replaced with the generated token. + """ + self.name = name + self.callback_url = callback_url + self.token_valid_for = token_valid_for + self.email_subject = email_subject + self._templates_directory = ( + Path(__file__).parent.parent / "static" / "email_templates" / "dist" + ) + + @property + def jwt_sub_format(self) -> str: + return f"{self.name}:{{}}" + + @property + def template_pseudo_filepath(self) -> Path: + """ + The mail template (.html compiled from .mjml file)'s path, + assuming the project root as the current directory. + The extension still needs to be added to get either the HTML version (.html) + or the plain-text one (.txt) + """ + return Path(self._templates_directory) / self.name + + def template_filepath(self, fmt: Union[Literal["html"], Literal["txt"]]) -> Path: + return Path(f"{self.template_pseudo_filepath}.{fmt}") + + @property + def _send_mail_function(self) -> Callable[[str, User], None]: + def send_mail(token: str, current_user: User): + yagmail.SMTP(getenv("GMAIL_USERNAME"), getenv("GMAIL_PASSWORD")).send( + to=current_user.email, + subject=self.email_subject.format(username=current_user.username), + contents=[ + self._render_mail_template_file( + "html", current_user=current_user, token=token + ), + ], + ) + + return send_mail + + def _action_url(self, token: str) -> str: + return self.callback_url.format(token) + + def _render_mail_template_file( + self, + fmt: Union[Literal["html"], Literal["txt"]], + token: str, + current_user: User, + ) -> str: + """ + Renders the mail template (at `self.mail_template_path()`) + and returns the HTML/plaintext string + """ + return self._render_mail_template( + template=Path(self.template_filepath(fmt)).read_text(), + token=token, + current_user=current_user, + ) + + def _render_mail_template( + self, template: str, token: str, current_user: User + ) -> str: + """ + Renders the mail template given and returns the rendered string + """ + return chevron.render( + template, + { + "url": self._action_url(token), + "token": token, + "username": current_user.username, + "email": current_user.email, + }, + ) + + def _send_request(self, current_user: User) -> None: + token = create_jwt_token( + self.jwt_sub_format, current_user.username, self.token_valid_for + ) + self._send_mail_function(token, current_user) + + def queue_request_sending(self, current_user: User, tasks: BackgroundTasks) -> None: + token = create_jwt_token( + self.jwt_sub_format, current_user.username, self.token_valid_for + ) + tasks.add_task(self._send_mail_function, token, current_user) + + def handle_token_verification(self, input_token: str, current_user: User): + if not verify_jwt_token( + input_token, self.jwt_sub_format, current_user.username + ): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail="The token is either corrupted, emitted by another user or expired", + ) diff --git a/schoolsyst_api/env.py b/schoolsyst_api/env.py index 84b80ce..4fc534a 100644 --- a/schoolsyst_api/env.py +++ b/schoolsyst_api/env.py @@ -8,3 +8,5 @@ class EnvironmentVariables(BaseModel): ARANGODB_USERNAME: str ARANGODB_HOST: AnyHttpUrl ARANGO_ROOT_PASSWORD: str + GMAIL_USERNAME: str + GMAIL_PASSWORD: str diff --git a/static/email_templates/_base.mjml b/static/email_templates/_base.mjml new file mode 100644 index 0000000..e69de29 diff --git a/static/email_templates/data_archive.mjml b/static/email_templates/data_archive.mjml new file mode 100644 index 0000000..e69de29 diff --git a/static/email_templates/data_archive.txt b/static/email_templates/data_archive.txt new file mode 100644 index 0000000..e69de29 diff --git a/static/email_templates/email_confirmation.mjml b/static/email_templates/email_confirmation.mjml new file mode 100644 index 0000000..e69de29 diff --git a/static/email_templates/email_confirmation.txt b/static/email_templates/email_confirmation.txt new file mode 100644 index 0000000..e69de29 diff --git a/static/email_templates/password_reset.mjml b/static/email_templates/password_reset.mjml new file mode 100644 index 0000000..e69de29 diff --git a/static/email_templates/password_reset.txt b/static/email_templates/password_reset.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_accounts_email_confirmation.py b/tests/unit/test_accounts_email_confirmation.py deleted file mode 100644 index f0d9b5a..0000000 --- a/tests/unit/test_accounts_email_confirmation.py +++ /dev/null @@ -1,16 +0,0 @@ -import os -from datetime import timedelta - -from jose import jwt -from schoolsyst_api.accounts.email_confirmation import ( - JWT_SUB_FORMAT, - create_email_confirmation_request_token, -) - - -def test_create_email_confirmation_request_token(): - token = create_email_confirmation_request_token("ewen-lbh", timedelta(hours=24)) - decoded = jwt.decode( - token, key=os.getenv("SECRET_KEY"), subject=JWT_SUB_FORMAT.format("ewen-lbh") - ) - assert decoded diff --git a/tests/unit/test_email_confirmed_action.py b/tests/unit/test_email_confirmed_action.py new file mode 100644 index 0000000..dfb7905 --- /dev/null +++ b/tests/unit/test_email_confirmed_action.py @@ -0,0 +1,102 @@ +from datetime import datetime, timedelta +from os import getenv +from pathlib import Path + +from schoolsyst_api.accounts.models import User +from schoolsyst_api.email_confirmed_action import EmailConfirmedAction +from tests import mocks + +MOCKS_MAIL_TEMPLATES_DIR = ( + Path(__file__).parent.parent / "mocks" / "email_templates" / "dist" +) + +eca = EmailConfirmedAction( + name="lorem", + callback_url="ipsum://{}", + token_valid_for=timedelta(1), + email_subject="Ut id ea voluptate enim laborum.", +) +eca._templates_directory = MOCKS_MAIL_TEMPLATES_DIR + + +def test_jwt_sub_format(): + assert eca.jwt_sub_format == "lorem:{}" + + +def test_template_pseudo_filepath(): + assert eca.template_pseudo_filepath == MOCKS_MAIL_TEMPLATES_DIR / "lorem" + + +def test_template_filepath(): + assert eca.template_filepath("html") == MOCKS_MAIL_TEMPLATES_DIR / "lorem.html" + assert eca.template_filepath("txt") == MOCKS_MAIL_TEMPLATES_DIR / "lorem.txt" + + +def test_send_mail_function(): + assert callable(eca._send_mail_function) + + +def test_action_url(): + assert eca._action_url("hmmm") == "ipsum://hmmm" + + +def test_render_mail_template(): + assert ( + eca._render_mail_template( + template="""\ +

Hello, {{username}}.

+Please click on here +Token: {{token}} +Sent to your email +""", + current_user=mocks.users.alice, + token="hmmm", + ) + == f"""\ +

Hello, {mocks.users.alice.username}.

+Please click on here +Token: hmmm +Sent to your email +""" + ) + + +def test_render_mail_template_file(): + assert ( + eca._render_mail_template_file( + "html", token="hmmm", current_user=mocks.users.alice + ) + == f"""\ +

Hello, {mocks.users.alice.username}.

+Please click on here +Token: hmmm +Sent to your email +""" + ) + assert ( + eca._render_mail_template_file( + "txt", token="hmmm", current_user=mocks.users.alice + ) + == f"""\ +Hello, {mocks.users.alice.username}. + +Please click here: ipsum://hmmm Token: hmmm Sent to your email: {mocks.users.alice.email} +""" + ) + + +def test_send_request(): + print( + f"""Running with gmail creds: +------------------------- +un {getenv('GMAIL_USERNAME')!r} +pw {getenv('GMAIL_PASSWORD')!r}""" + ) + eca._send_request( + User( + username="ewen-lbh", + email=getenv("GMAIL_PASSWORD"), + email_is_confirmed=True, + joined_at=datetime.now(), + ) + )