Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ generating emails for account verification and reset password #81

Merged
merged 22 commits into from
May 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3471f64
🎨 refactored project name to config
eladgunders May 12, 2023
4e5114b
✨ added smtp variables to config
eladgunders May 12, 2023
5ebc224
✨ added email template for reset password
eladgunders May 12, 2023
85b431c
✨ added emails and jinja2
eladgunders May 12, 2023
f90eea5
upgraded uvicorn version because of logging bug - https://github.com/…
eladgunders May 12, 2023
c35bb34
🎨 refactored utils folder structure
eladgunders May 12, 2023
a9ccfcd
✨ handling forget password bt sending an email
eladgunders May 12, 2023
ed01727
💄 cosmetic
eladgunders May 12, 2023
6a63d53
🔥 removed unused code
eladgunders May 12, 2023
6e0ce6d
✨ handling account verification by sending an email
eladgunders May 12, 2023
d18c5b9
🔥 removed unused parameters
eladgunders May 12, 2023
8b9d2e2
added logs
eladgunders May 12, 2023
152df73
renamed function
eladgunders May 12, 2023
c092170
import utils from its __init__.py
eladgunders May 13, 2023
0a8467b
added reset_password_token_lifetime_seconds and verification_token_se…
eladgunders May 13, 2023
6539dd6
added the reset password and user verification lifetime to the email …
eladgunders May 13, 2023
e5dec4a
added validator for EMAILS_FROM_NAME
eladgunders May 13, 2023
289c2b9
modified docker-compose env variables
eladgunders May 13, 2023
62972b2
added env variables section in README.md
eladgunders May 13, 2023
99ebb1c
fixed mypy
eladgunders May 13, 2023
ea47f7b
fixed bandit
eladgunders May 13, 2023
9471c9c
added required env variables to ci
eladgunders May 13, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ env:
POSTGRES_PASSWORD: password
CORS_ORIGINS: http://127.0.0.1:8000
JWT_SECRET_KEY: secret
SMTP_HOST: smtp.gmail.com
SMTP_PORT: 587
SMTP_USER: random.user@gmail.com
SMTP_PASSWORD: random.password
EMAILS_FROM_EMAIL: random.user@gmail.com
FRONT_END_BASE_URL: http://localhost:3000
PYTHONPATH: ./todos

jobs:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ Clone the repository and navigate to its directory:
$ git clone https://github.com/eladgunders/fastapi-todos.git
$ cd fastapi-todos

### Setting up environment variables ⚙️

To properly configure the application, you'll need to define the following environment variables in the ```web-variables.env``` file:

- ```CORS_ORIGINS```: a comma-separated list of allowed origins for Cross-Origin Resource Sharing (CORS).
- ```FRONT_END_BASE_URL```: the base URL of your frontend application.
- ```SMTP_HOST```: the hostname or IP address of the SMTP server that will be used for sending emails.
- ```SMTP_PORT```: the port number of the SMTP server. Usually, this is 587.
- ```SMTP_USER```: the username for authenticating with the SMTP server.
- ```SMTP_PASSWORD```: the password for authenticating with the SMTP server.
- ```EMAILS_FROM_EMAIL```: the email address that will appear as the sender of all system-generated emails.
- ```EMAILS_FROM_NAME```: the name that will appear as the sender of all system-generated emails.

### Running the application with Docker Compose 🐳

To run the application locally, you will need to have [Docker](https://docs.docker.com/get-docker/)
Expand Down
3 changes: 3 additions & 0 deletions db-variables.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POSTGRES_DB=todos
POSTGRES_USER=username
POSTGRES_PASSWORD=password
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ services:
web:
env_file:
- ./web-variables.env
- ./db-variables.env
build: .
ports:
- 8000:8000
Expand All @@ -12,7 +13,7 @@ services:

db:
env_file:
- ./web-variables.env
- ./db-variables.env
image: postgres:13-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
Expand Down
6 changes: 4 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ fastapi-users[sqlalchemy]==10.1.5
fastapi-users-db-sqlalchemy==4.0.3
starlette==0.20.4
SQLAlchemy==1.4.41
uvicorn==0.18.3
uvicorn==0.22.0
asyncpg==0.26.0
alembic==1.5.8
alembic==1.5.8
emails==0.5.15
jinja2==3.1.2
34 changes: 33 additions & 1 deletion todos/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from functools import lru_cache
from typing import Any, Optional

from pydantic import BaseSettings, PostgresDsn, AnyHttpUrl, validator, SecretStr
from pydantic import BaseSettings, PostgresDsn, AnyHttpUrl, validator, SecretStr, EmailStr


class Settings(BaseSettings):
PROJECT_NAME: str = 'Todos API'
API_V1_STR: str = '/api/v1'
JWT_SECRET_KEY: SecretStr

Expand Down Expand Up @@ -32,6 +33,37 @@ def assemble_db_connection(cls, _: str, values: dict[str, Any]) -> str:
path=f'/{values.get("POSTGRES_DB")}',
)

SMTP_TLS: bool = True
SMTP_HOST: Optional[str] = None
SMTP_PORT: Optional[int] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[SecretStr] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = None

@validator('EMAILS_FROM_NAME')
def get_project_name(cls, v: Optional[str], values: dict[str, Any]) -> str:
if not v:
return values['PROJECT_NAME']
return v

EMAIL_TEMPLATES_DIR: str = './todos/app/email-templates'
EMAILS_ENABLED: bool = False

@validator('EMAILS_ENABLED', pre=True)
def get_emails_enabled(cls, _: bool, values: dict[str, Any]) -> bool:
return all([
values.get('SMTP_HOST'),
values.get('SMTP_PORT'),
values.get('EMAILS_FROM_EMAIL')
])

# 60 seconds by 60 minutes (1 hour) and then by 12 (for 12 hours total)
RESET_PASSWORD_TOKEN_LIFETIME_SECONDS: int = 60 * 60 * 12
VERIFY_TOKEN_LIFETIME_SECONDS: int = 60 * 60 * 12

FRONT_END_BASE_URL: AnyHttpUrl

class Config:
env_file = '.env'
case_sensitive = True
Expand Down
26 changes: 26 additions & 0 deletions todos/app/email-templates/account_verification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ project_name }} - Account Verification</title>
</head>
<body style="background-color: #f5f5f5; font-family: Arial, sans-serif;">
<div style="background-color: #fff; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="border-bottom: 1px solid #e1e1e1;">
<h1 style="font-size: 24px; color: #333; font-weight: 400;">{{ project_name }} - Account Verification</h1>
</div>
<div style="margin-top: 20px;">
<p style="font-size: 16px; color: #555;">Welcome to {{ project_name }}!</p>
<p style="font-size: 16px; color: #555;">We received a request for account verification for email <strong>{{ email }}</strong>.</p>
<p style="font-size: 16px; color: #555;">Please click the button below to verify your account:</p>
<div style="margin-top: 20px; text-align: center;">
<a href="{{ link }}" style="display: inline-block; background-color: #007bff; color: #fff; text-decoration: none; font-size: 16px; font-weight: 600; padding: 12px 30px; border-radius: 5px;">Verify Account</a>
</div>
</div>
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 20px;">
<p style="font-size: 14px; color: #555;">The account verification button will expire in {{ expire_hours }} hours</p>
<p style="font-size: 14px; color: #555;">If you did not ask for an account verification on {{ project_name }}, you can disregard this email.</p>
</div>
</div>
</body>
</html>
25 changes: 25 additions & 0 deletions todos/app/email-templates/reset_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ project_name }} - Password Recovery</title>
</head>
<body style="background-color: #f5f5f5; font-family: Arial, sans-serif;">
<div style="background-color: #fff; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="border-bottom: 1px solid #e1e1e1;">
<h1 style="font-size: 24px; color: #333; font-weight: 400;">{{ project_name }} - Password Recovery</h1>
</div>
<div style="margin-top: 20px;">
<p style="font-size: 16px; color: #555;">We received a request to recover the password for email <strong>{{ email }}</strong>.</p>
<p style="font-size: 16px; color: #555;">Reset your password by clicking the button below:</p>
<div style="margin-top: 20px; text-align: center;">
<a href="{{ link }}" style="display: inline-block; background-color: #007bff; color: #fff; text-decoration: none; font-size: 16px; font-weight: 600; padding: 12px 30px; border-radius: 5px;">Reset Password</a>
</div>
</div>
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 20px;">
<p style="font-size: 14px; color: #555;">The reset password button will expire in {{ expire_hours }} hours</p>
<p style="font-size: 14px; color: #555;">If you didn't request a password recovery, you can disregard this email.</p>
</div>
</div>
</body>
</html>
2 changes: 1 addition & 1 deletion todos/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
config = get_config()

app = FastAPI(
title='TODOS API',
title=config.PROJECT_NAME,
openapi_url=f'{config.API_V1_STR}/openapi.json'
)

Expand Down
27 changes: 27 additions & 0 deletions todos/app/users/manager.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
import uuid
from typing import Optional
import logging

from pydantic import SecretStr
from fastapi import Request
from fastapi_users import BaseUserManager, UUIDIDMixin

from app.core.config import get_config
from app.models.tables import User
from app.utils import send_reset_password_email, send_account_verification_email


config = get_config()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret: SecretStr = config.JWT_SECRET_KEY
reset_password_token_lifetime_seconds: int = config.RESET_PASSWORD_TOKEN_LIFETIME_SECONDS
verification_token_secret: SecretStr = config.JWT_SECRET_KEY
verification_token_lifetime_seconds: int = config.VERIFY_TOKEN_LIFETIME_SECONDS

async def on_after_forgot_password(
self,
user: User,
token: str,
request: Optional[Request] = None
) -> None:
send_reset_password_email(email_to=user.email, token=token)
logger.info('sent reset password email to %s', user.email)

async def on_after_request_verify(
self,
user: User,
token: str,
request: Optional[Request] = None
) -> None:
send_account_verification_email(email_to=user.email, token=token)
logger.info('sent account verification email to %s', user.email)
2 changes: 2 additions & 0 deletions todos/app/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .emails import send_reset_password_email, send_account_verification_email
from .exceptions import exception_handler
78 changes: 78 additions & 0 deletions todos/app/utils/emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from typing import Any, Optional
import logging

from emails import Message
from emails.template import JinjaTemplate

from app.core.config import get_config


config = get_config()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def send_email(
*,
email_to: str,
environment: Optional[dict[str, Any]],
subject_template: str = "",
html_template: str = "",
) -> None:
if not config.EMAILS_ENABLED:
raise RuntimeError('no configuration provided for email variables')
if not environment:
environment = {}
message = Message(
subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template),
mail_from=(config.EMAILS_FROM_NAME, config.EMAILS_FROM_EMAIL),
)
smtp_options = {'host': config.SMTP_HOST, 'port': config.SMTP_PORT}
if config.SMTP_TLS:
smtp_options['tls'] = True
if config.SMTP_USER:
smtp_options['user'] = config.SMTP_USER
if config.SMTP_PASSWORD:
smtp_options['password'] = config.SMTP_PASSWORD.get_secret_value()
res = message.send(to=email_to, render=environment, smtp=smtp_options)
logger.info('send email result %s', res)


def send_reset_password_email(*, email_to: str, token: str) -> None:
subject = f'{config.PROJECT_NAME} - Password recovery for email {email_to}'
with open(f'{config.EMAIL_TEMPLATES_DIR}/reset_password.html', 'r', encoding='utf-8') as f:
template_str = f.read()
link = f'{config.FRONT_END_BASE_URL}/reset-password?token={token}'
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
'project_name': config.PROJECT_NAME,
'email': email_to,
'link': link,
# dividing by 3600 to get the number of hours from the number of seconds
'expire_hours': config.RESET_PASSWORD_TOKEN_LIFETIME_SECONDS / 3600,
}
)


def send_account_verification_email(*, email_to: str, token: str) -> None:
subject = f'{config.PROJECT_NAME} - Account verification for email {email_to}'
with open(f'{config.EMAIL_TEMPLATES_DIR}/account_verification.html', 'r', encoding='utf-8') as f:
template_str = f.read()
link = f'{config.FRONT_END_BASE_URL}/verify-account?token={token}'
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
'project_name': config.PROJECT_NAME,
'email': email_to,
'link': link,
# dividing by 3600 to get the number of hours from the number of seconds
'expire_hours': config.VERIFY_TOKEN_LIFETIME_SECONDS / 3600,
}
)
File renamed without changes.
12 changes: 8 additions & 4 deletions web-variables.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
JWT_SECRET_KEY=SECRET
CORS_ORIGINS=http://localhost:3000;http://127.0.0.1:8000
POSTGRES_DB=todos
POSTGRES_HOST=db:5432
POSTGRES_USER=username
POSTGRES_PASSWORD=password
CORS_ORIGINS=http://localhost:3000;http://127.0.0.1:8000
FRONT_END_BASE_URL=http://localhost:3000
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
EMAILS_FROM_EMAIL=
EMAILS_FROM_NAME=