Skip to content

Commit

Permalink
feat: implement user email verification workflow (#177) (#184)
Browse files Browse the repository at this point in the history
Co-authored-by: gromdimon <[email protected]>
Since there's a separate issue for testing (#174 ), I'm closing this PR.
  • Loading branch information
holtgrewe authored Nov 8, 2023
1 parent a8f2f21 commit 27c7d98
Show file tree
Hide file tree
Showing 27 changed files with 1,035 additions and 471 deletions.
4 changes: 2 additions & 2 deletions backend/Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
DIRS_PYTHON_NO_ALEMBIC := app tests ../docs
DIRS_PYTHON_NO_ALEMBIC := app tests ../docs stubs
DIRS_PYTHON := alembic $(DIRS_PYTHON_NO_ALEMBIC)

.PHONY: help
Expand Down Expand Up @@ -58,7 +58,7 @@ flake8:

.PHONY: lint-mypy
lint-mypy:
pipenv run mypy $(DIRS_PYTHON_NO_ALEMBIC)
MYPYPATH=$(PWD)/stubs pipenv run mypy $(DIRS_PYTHON_NO_ALEMBIC)

.PHONY: test
test:
Expand Down
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ alembic = "*"
requests = "*"
types-requests = "*"
greenlet = "*"
emails = "*"

[dev-packages]
aiosqlite = "*"
Expand Down
935 changes: 533 additions & 402 deletions backend/Pipfile.lock

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions backend/app/api/api_v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
from httpx_oauth.clients.openid import OpenID
from httpx_oauth.errors import GetIdEmailError

from app.api.api_v1.endpoints import adminmsgs, auth, bookmarks, caseinfo
from app.api.api_v1.endpoints import adminmsgs, auth, bookmarks, caseinfo, utils
from app.core.auth import auth_backend_bearer, auth_backend_cookie, fastapi_users
from app.core.config import settings
from app.schemas.user import UserRead, UserUpdate

api_router = APIRouter()
api_router.include_router(adminmsgs.router, prefix="/adminmsgs", tags=["adminmsgs"])
api_router.include_router(bookmarks.router, prefix="/bookmarks", tags=["bookmarks"])
api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
api_router.include_router(caseinfo.router, prefix="/caseinfo", tags=["caseinfo"])

api_router.include_router(
Expand All @@ -31,11 +32,11 @@
# prefix="/auth",
# tags=["auth"],
# )
# api_router.include_router(
# fastapi_users.get_verify_router(UserRead),
# prefix="/auth",
# tags=["auth"],
# )
api_router.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
api_router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
Expand Down Expand Up @@ -88,6 +89,8 @@ async def get_id_email(self, token: str) -> Tuple[str, Optional[str]]:
raise GetIdEmailError(response_user.json())
data_record: Dict[str, Any] = response_record.json()

# Try to get email from person if "trusted parties" are allowed
# to do so. Otherwise, put a placeholder email.
data_record_emails = data_record.get("person", {}).get("emails", {}).get("email", [])
if data_record_emails:
email = data_record_emails[0].get("email", None)
Expand Down
12 changes: 8 additions & 4 deletions backend/app/api/api_v1/endpoints/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
router = APIRouter()

current_active_user = auth.fastapi_users.current_user(active=True)
current_superuser = auth.fastapi_users.current_user(active=True, superuser=True)
current_active_superuser = auth.fastapi_users.current_user(active=True, superuser=True)


@router.post("/create", response_model=schemas.BookmarkCreate)
Expand All @@ -32,7 +32,7 @@ async def create_bookmark(

@router.get(
"/list-all",
dependencies=[Depends(current_superuser)],
dependencies=[Depends(current_active_superuser)],
response_model=list[schemas.BookmarkRead],
)
async def list_bookmarks(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(deps.get_db)):
Expand All @@ -50,7 +50,9 @@ async def list_bookmarks(skip: int = 0, limit: int = 100, db: AsyncSession = Dep


@router.get(
"/get-by-id", dependencies=[Depends(current_superuser)], response_model=schemas.BookmarkRead
"/get-by-id",
dependencies=[Depends(current_active_superuser)],
response_model=schemas.BookmarkRead,
)
async def get_bookmark(id: str, db: AsyncSession = Depends(deps.get_db)):
"""
Expand All @@ -69,7 +71,9 @@ async def get_bookmark(id: str, db: AsyncSession = Depends(deps.get_db)):


@router.delete(
"/delete-by-id", response_model=schemas.BookmarkRead, dependencies=[Depends(current_superuser)]
"/delete-by-id",
response_model=schemas.BookmarkRead,
dependencies=[Depends(current_active_superuser)],
)
async def delete_bookmark(id: str, db: AsyncSession = Depends(deps.get_db)):
"""
Expand Down
23 changes: 23 additions & 0 deletions backend/app/api/api_v1/endpoints/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any

from fastapi import APIRouter, Depends
from pydantic.networks import EmailStr

from app import models, schemas
from app.api import deps
from app.app.utils import send_test_email
from app.core import auth

router = APIRouter()

current_active_superuser = auth.fastapi_users.current_user(active=True, superuser=True)


@router.post("/test-email/", response_model=schemas.Msg, status_code=201)
def test_email(
email_to: EmailStr,
current_user: models.User = Depends(current_active_superuser),
) -> Any:
"""Send out a test email."""
send_test_email(email_to=email_to)
return {"msg": "Test email sent"}
Empty file added backend/app/app/__init__.py
Empty file.
1 change: 1 addition & 0 deletions backend/app/app/email-templates/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!build
25 changes: 25 additions & 0 deletions backend/app/app/email-templates/build/test_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
.ReadMsgBody { width:100%; }
.ExternalClass { width:100%; }
.ExternalClass * { line-height:100%; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
@-ms-viewport { width:320px; }
@viewport { width:320px; }
}</style><!--<![endif]--><!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]--><!--[if lte mso 11]>
<style type="text/css">
.outlook-group-fix { width:100% !important; }
</style>
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}</style><style type="text/css"></style></head><body style="background-color:#ffffff;"><div style="background-color:#ffffff;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:helvetica;font-size:20px;line-height:1;text-align:left;color:#555555;">{{ project_name }}</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Test email for: {{ email }}</div></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
21 changes: 21 additions & 0 deletions backend/app/app/email-templates/build/verify_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }</style><!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]--><!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}</style><style media="screen and (min-width:480px)">.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }</style><style type="text/css"></style></head><body style="word-spacing:normal;background-color:#ffffff;"><div style="background-color:#ffffff;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tbody><tr><td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 4px #555555;font-size:1px;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 4px #555555;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:helvetica;font-size:20px;line-height:1;text-align:left;color:#555555;">{{ project_name }}</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Your email: {{ email }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><a href="{{ base_url }}/verify-email?token={{token}}">Click here to verify your email</a></div></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
11 changes: 11 additions & 0 deletions backend/app/app/email-templates/src/test_email.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<mjml>
<mj-body background-color="#fff">
<mj-section>
<mj-column>
<mj-divider border-color="#555"></mj-divider>
<mj-text font-size="20px" color="#555" font-family="helvetica">{{ project_name }}</mj-text>
<mj-text font-size="16px" color="#555">Test email for: {{ email }}</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
16 changes: 16 additions & 0 deletions backend/app/app/email-templates/src/verify_email.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<mjml>
<mj-body background-color="#fff">
<mj-section>
<mj-column>
<mj-divider border-color="#555"></mj-divider>
<mj-text font-size="20px" color="#555" font-family="helvetica">{{ project_name }}</mj-text>
<mj-text font-size="16px" color="#555">Your email: {{ email }}</mj-text>
<mj-text font-size="16px" color="#555" align="center">
<a href="{{ base_url }}/verify-email?token={{token}}">
Click here to verify your email
</a>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
70 changes: 70 additions & 0 deletions backend/app/app/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Optional

import emails
from emails.template import JinjaTemplate
from starlette.requests import Request

from app.core.config import settings


def send_email(
email_to: str,
subject_template: str = "",
html_template: str = "",
environment: Dict[str, Any] = {},
) -> None:
assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
message = emails.Message(
subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template),
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
)
smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
if settings.SMTP_TLS:
smtp_options["tls"] = True
if settings.SMTP_USER:
smtp_options["user"] = settings.SMTP_USER
if settings.SMTP_PASSWORD:
smtp_options["password"] = settings.SMTP_PASSWORD
response = message.send(to=email_to, render=environment, smtp=smtp_options)
logging.info(f"send email result: {response}")


def send_test_email(email_to: str) -> None:
"""Send out test email to ``email_to``"""
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Test Email"
with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f:
template_str = f.read()
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={"project_name": settings.PROJECT_NAME, "email": email_to},
)


def send_user_verify_email(email_to: str, token: str, request: Request | None = None) -> None:
"""Send out user verification email"""
if request:
base_url = f"{request.url.components.scheme}://{request.url.components.netloc}"
else:
base_url = str(settings.SERVER_HOST)
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Verify Your Email"
with open(Path(settings.EMAIL_TEMPLATES_DIR) / "verify_email.html") as f:
template_str = f.read()
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
"project_name": settings.PROJECT_NAME,
"email": email_to,
"token": token,
"base_url": base_url,
},
)
17 changes: 14 additions & 3 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import uuid
from typing import Any
from typing import Any, Optional

import redis.asyncio
from fastapi import Depends, Request, Response
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
CookieTransport,
RedisStrategy,
)
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import PasswordHelperProtocol
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_async_session
from app.app import utils
from app.core.config import settings
from app.models.user import OAuthAccount, User

Expand All @@ -26,6 +29,13 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = settings.SECRET_KEY
verification_token_secret = settings.SECRET_KEY

def __init__(
self,
user_db: BaseUserDatabase[User, uuid.UUID],
password_helper: Optional[PasswordHelperProtocol] = None,
):
super().__init__(user_db, password_helper)

async def on_after_register(self, user: User, request: Request | None = None):
print(f"User {user.id} has registered.")

Expand All @@ -35,7 +45,8 @@ async def on_after_forgot_password(
print(f"User {user.id} has forgot their password. Reset token: {token}")

async def on_after_request_verify(self, user: User, token: str, request: Request | None = None):
print(f"Verification requested for user {user.id}. Verification token: {token}")
"""Callback after requesting verification."""
utils.send_user_verify_email(user.email, token, request)


async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
Expand Down
Loading

0 comments on commit 27c7d98

Please sign in to comment.