Skip to content

Commit

Permalink
feat: implement user email verification workflow (#177)
Browse files Browse the repository at this point in the history
  • Loading branch information
holtgrewe authored and gromdimon committed Nov 6, 2023
1 parent 15c6007 commit 11a00c2
Show file tree
Hide file tree
Showing 11 changed files with 597 additions and 374 deletions.
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
825 changes: 484 additions & 341 deletions backend/Pipfile.lock

Large diffs are not rendered by default.

13 changes: 7 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, utils, caseinfo
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
20 changes: 20 additions & 0 deletions backend/app/api/api_v1/endpoints/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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

router = APIRouter()


@router.post("/test-email/", response_model=schemas.Msg, status_code=201)
def test_email(
email_to: EmailStr,
current_user: models.User = Depends(deps.get_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.
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>
45 changes: 45 additions & 0 deletions backend/app/app/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 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:
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},
)
13 changes: 11 additions & 2 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import uuid
from typing import Any
from typing import Any, Optional

import redis.asyncio
from fastapi import Depends, Request, Response
Expand All @@ -11,6 +11,9 @@
RedisStrategy,
)
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from fastapi_users import models
from fastapi_users.db import BaseUserDatabase
from fastapi_users.password import PasswordHelperProtocol
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_async_session
Expand All @@ -26,6 +29,11 @@ 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[models.UP, models.ID],
password_helper: Optional[PasswordHelperProtocol] = None,
):
super().__init__(self, 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 +43,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."""
self.email.send_user_validation(user.email, token, request and request.headers)


async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
Expand Down
37 changes: 12 additions & 25 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import secrets
from typing import Any
import typing

from pydantic import AnyHttpUrl, EmailStr, HttpUrl, PostgresDsn, field_validator
from pydantic_core.core_schema import ValidationInfo
Expand Down Expand Up @@ -156,31 +157,17 @@ def assemble_db_connection(cls, v: str | None, info: ValidationInfo) -> Any:

# -- Email Sending Configuration -----------------------------------------

# SMTP_TLS: bool = True
# SMTP_PORT: int | None = None
# SMTP_HOST: str | None = None
# SMTP_USER: str | None = None
# SMTP_PASSWORD: str | None = None
# EMAILS_FROM_EMAIL: EmailStr | None = None
# EMAILS_FROM_NAME: str | None = None

# @validator("EMAILS_FROM_NAME")
# def get_project_name(cls, v: str | None, values: Dict[str, Any]) -> str:
# if not v:
# return values["PROJECT_NAME"]
# return v

# EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
# EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
# EMAILS_ENABLED: bool = False

# @validator("EMAILS_ENABLED", pre=True)
# def get_emails_enabled(cls, v: bool, values: Dict[str, Any]) -> bool:
# return bool(
# values.get("SMTP_HOST")
# and values.get("SMTP_PORT")
# and values.get("EMAILS_FROM_EMAIL")
# )
SMTP_TLS: bool = False
SMTP_PORT: int | None = None
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
EMAILS_FROM_EMAIL: EmailStr | None = None
EMAILS_FROM_NAME: str | None = None

EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
EMAILS_ENABLED: bool = False

# -- Sentry Configuration ------------------------------------------------

Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
from app.schemas.auth import OAuth2ProviderConfig, OAuth2ProviderPublic # noqa
from app.schemas.bookmark import BookmarkCreate, BookmarkRead, BookmarkUpdate # noqa
from app.schemas.caseinfo import CaseInfoCreate, CaseInfoRead, CaseInfoUpdate # noqa
from app.schemas.msg import Msg # noqa
from app.schemas.user import UserCreate, UserRead, UserUpdate # noqa
5 changes: 5 additions & 0 deletions backend/app/schemas/msg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel


class Msg(BaseModel):
msg: str

0 comments on commit 11a00c2

Please sign in to comment.