Skip to content

Commit

Permalink
Merge pull request #942 from MikeSoft007/bugfix/waitlist_email
Browse files Browse the repository at this point in the history
Bugfix: implement rate limiting for auth endpoints
  • Loading branch information
johnson-oragui authored Aug 23, 2024
2 parents 50312de + 609bddd commit fd78c63
Show file tree
Hide file tree
Showing 12 changed files with 376 additions and 101 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
test_case1.py
api/core/dependencies/mailjet.py
tests/v1/waitlist/waitlist_test.py

# PyInstaller
# Usually these files are written by a python script from a template
Expand All @@ -36,6 +38,7 @@ api/core/dependencies/mailjet.py

# Installer logs
pip-log.txt
test_case1.py
pip-delete-this-directory.txt

# Unit test / coverage reports
Expand Down
56 changes: 56 additions & 0 deletions api/core/dependencies/email/templates/waitlist.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{% extends 'base.html' %}

{% block title %}Welcome{% endblock %}

{% block content %}
<table role="presentation" width="100%" style="padding: 3.5rem;">
<tr>
<td>
<div style="text-align: center; margin-bottom: 1.5rem;">
<h1 style="font-size: 1.5rem; color: #0A0A0A; font-weight: 600;">Welcome to Boilerplate Waitlist</h1>
<p style="font-size: 1.125rem; color: rgba(0, 0, 0, 0.8); font-weight: 500;">Thanks for signing up</p>
</div>

<div>
<p style="color: #111; font-size: 1.125rem; font-weight: 600;">Hi {{name}}</p>
<p style="color: rgba(17, 17, 17, 0.9); font-weight: 400;">We're thrilled to have you join our waitlist. Experience quality and innovation
like never before. Our product is made to fit your needs and make your
life easier.</p>
</div>

<div style="margin-bottom: 1.75rem;">
<h3 style="color: #0A0A0A; font-weight: 600;">Here's what you can look forward to.</h3>
<div style="margin-bottom: 1.25rem;">
<ul>
<li>
<span style="font-weight: 600;">Exclusive Offers:</span> Enjoy special promotions and
discounts available only to our members.
</li>
<li>
<span style="font-weight: 600;">Exclusive Offers:</span> Enjoy special promotions and
discounts available only to our members.
</li>
<li>
<span style="font-weight: 600;">Exclusive Offers:</span> Enjoy special promotions and
discounts available only to our members.
</li>
</ul>
</div>
</div>

<a href="{{cta_link}}" style="display: block; width: fit-content; padding: 0.5rem 2.5rem; background-color: #F97316; color: white; text-decoration: none; border-radius: 0.5rem; margin: 0 auto; text-align: center;">
Learn more about us
</a>

<!-- <div style="margin-top: 2rem;">
<p style="color: #111; font-size: 0.875rem; font-weight: 500;">Thank you for joining Boilerplate</p>
</div> -->

<div style="margin-top: 2rem;">
<p>Regards,</p>
<p>Boilerplate</p>
</div>
</td>
</tr>
</table>
{% endblock %}
4 changes: 2 additions & 2 deletions api/core/dependencies/email_sender.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from typing import Optional
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType

from api.utils.settings import settings
from premailer import transform



async def send_email(
Expand All @@ -11,7 +12,6 @@ async def send_email(
context: Optional[dict] = None
):
from main import email_templates
from premailer import transform

conf = ConnectionConfig(
MAIL_USERNAME=settings.MAIL_USERNAME,
Expand Down
35 changes: 28 additions & 7 deletions api/v1/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from datetime import timedelta
from slowapi import Limiter
from slowapi.util import get_remote_address

from fastapi import (BackgroundTasks, Depends,
status, APIRouter,
Response, Request)
Expand Down Expand Up @@ -27,9 +30,12 @@

auth = APIRouter(prefix="/auth", tags=["Authentication"])

# Initialize rate limiter
limiter = Limiter(key_func=get_remote_address)

@auth.post("/register", status_code=status.HTTP_201_CREATED, response_model=auth_response)
def register(background_tasks: BackgroundTasks, response: Response, user_schema: UserCreate, db: Session = Depends(get_db)):
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
def register(request: Request, background_tasks: BackgroundTasks, response: Response, user_schema: UserCreate, db: Session = Depends(get_db)):
'''Endpoint for a user to register their account'''

# Create user account
Expand Down Expand Up @@ -88,7 +94,8 @@ def register(background_tasks: BackgroundTasks, response: Response, user_schema:


@auth.post(path="/register-super-admin", status_code=status.HTTP_201_CREATED, response_model=auth_response)
def register_as_super_admin(user: UserCreate, db: Session = Depends(get_db)):
@limiter.limit("1000/minute") # Limit to 5 requests per minute per IP
def register_as_super_admin(request: Request, user: UserCreate, db: Session = Depends(get_db)):
"""Endpoint for super admin creation"""

user = user_service.create_admin(db=db, schema=user)
Expand Down Expand Up @@ -131,7 +138,8 @@ def register_as_super_admin(user: UserCreate, db: Session = Depends(get_db)):


@auth.post("/login", status_code=status.HTTP_200_OK, response_model=auth_response)
def login(login_request: LoginRequest, db: Session = Depends(get_db)):
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)):
"""Endpoint to log in a user"""

# Authenticate the user
Expand Down Expand Up @@ -171,7 +179,9 @@ def login(login_request: LoginRequest, db: Session = Depends(get_db)):


@auth.post("/logout", status_code=status.HTTP_200_OK)
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
def logout(
request: Request,
response: Response,
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user),
Expand All @@ -187,6 +197,7 @@ def logout(


@auth.post("/refresh-access-token", status_code=status.HTTP_200_OK)
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
def refresh_access_token(
request: Request, response: Response, db: Session = Depends(get_db)
):
Expand Down Expand Up @@ -220,7 +231,8 @@ def refresh_access_token(


@auth.post("/request-token", status_code=status.HTTP_200_OK)
async def request_signin_token(background_tasks: BackgroundTasks,
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
async def request_signin_token(request: Request, background_tasks: BackgroundTasks,
email_schema: EmailRequest, db: Session = Depends(get_db)
):
"""Generate and send a 6-digit sign-in token to the user's email"""
Expand Down Expand Up @@ -253,7 +265,9 @@ async def request_signin_token(background_tasks: BackgroundTasks,


@auth.post("/verify-token", status_code=status.HTTP_200_OK, response_model=auth_response)
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
async def verify_signin_token(
request: Request,
token_schema: TokenRequest, db: Session = Depends(get_db)
):
"""Verify the 6-digit sign-in token and log in the user"""
Expand Down Expand Up @@ -294,11 +308,13 @@ async def verify_signin_token(

# TODO: Fix magic link authentication
@auth.post("/magic-link", status_code=status.HTTP_200_OK)
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
def request_magic_link(
request: MagicLinkRequest, background_tasks: BackgroundTasks,
request: Request,
requests: MagicLinkRequest, background_tasks: BackgroundTasks,
response: Response, db: Session = Depends(get_db)
):
user = user_service.fetch_by_email(db=db, email=request.email)
user = user_service.fetch_by_email(db=db, email=requests.email)
magic_link_token = user_service.create_access_token(user_id=user.id)
magic_link = f"https://anchor-python.teams.hng.tech/login/magic-link?token={magic_link_token}"

Expand All @@ -319,7 +335,8 @@ def request_magic_link(


@auth.post("/magic-link/verify")
async def verify_magic_link(token_schema: Token, db: Session = Depends(get_db)):
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
async def verify_magic_link(request: Request, token_schema: Token, db: Session = Depends(get_db)):
user, access_token = AuthService.verify_magic_token(token_schema.token, db)
user_organizations = organisation_service.retrieve_user_organizations(user, db)

Expand Down Expand Up @@ -352,7 +369,9 @@ async def verify_magic_link(token_schema: Token, db: Session = Depends(get_db)):


@auth.put("/password", status_code=200)
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
async def change_password(
request: Request,
schema: ChangePasswordSchema,
db: Session = Depends(get_db),
user: User = Depends(user_service.get_current_user),
Expand All @@ -369,7 +388,9 @@ async def change_password(
@auth.get("/@me",
status_code=status.HTTP_200_OK,
response_model=AuthMeResponse)
@limiter.limit("1000/minute") # Limit to 1000 requests per minute per IP
def get_current_user_details(
request: Request,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(user_service.get_current_user)],
):
Expand Down
45 changes: 24 additions & 21 deletions api/v1/routes/waitlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from api.utils.json_response import JsonResponseDict
from fastapi.exceptions import HTTPException
from sqlalchemy.exc import IntegrityError

from fastapi import APIRouter, HTTPException, Depends, Request, status
from api.core.dependencies.email_sender import send_email
from fastapi import APIRouter, HTTPException, Depends, Request, status, BackgroundTasks
from sqlalchemy.orm import Session
from api.v1.schemas.waitlist import WaitlistAddUserSchema
from api.v1.services.waitlist_email import (
Expand All @@ -21,11 +21,7 @@

waitlist = APIRouter(prefix="/waitlist", tags=["Waitlist"])


@waitlist.post("/", response_model=success_response, status_code=201)
async def waitlist_signup(
request: Request, user: WaitlistAddUserSchema, db: Session = Depends(get_db)
):
def process_waitlist_signup(user: WaitlistAddUserSchema, db: Session):
if not user.full_name:
logger.error("Full name is required")
raise HTTPException(
Expand All @@ -50,22 +46,29 @@ async def waitlist_signup(
)

db_user = add_user_to_waitlist(db, user.email, user.full_name)
return db_user

try:
# await send_confirmation_email(user.email, user.full_name)
logger.info(f"Confirmation email sent successfully to {user.email}")
except HTTPException as e:
logger.error(f"Failed to send confirmation email: {e.detail}")
raise HTTPException(
status_code=500,
detail={
"message": "Failed to send confirmation email",
"success": False,
"status_code": 500,
},
@waitlist.post("/", response_model=success_response, status_code=201)
async def waitlist_signup(
background_tasks: BackgroundTasks,
request: Request,
user: WaitlistAddUserSchema,
db: Session = Depends(get_db)
):
db_user = process_waitlist_signup(user, db)
if db_user:
cta_link = 'https://anchor-python.teams.hng.tech/about-us'
# Send email in the background
background_tasks.add_task(
send_email,
recipient=user.email,
template_name='waitlist.html',
subject='Welcome to HNG Waitlist',
context={
'name': user.full_name,
'cta_link': cta_link
}
)

logger.info(f"User signed up successfully: {user.email}")
return success_response(message="You are all signed up!", status_code=201)


Expand Down
7 changes: 7 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import uvicorn, os
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from fastapi.templating import Jinja2Templates
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
Expand Down Expand Up @@ -34,6 +36,11 @@ async def lifespan(app: FastAPI):
version="1.0.0",
)


# Initialize the rate limiter
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

# Set up email templates and css static files
email_templates = Jinja2Templates(directory='api/core/dependencies/email/templates')

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ filelock==3.15.4
flake8==7.1.0
frozenlist==1.4.1
greenlet==3.0.3
slowapi==0.1.9
h11==0.14.0
httpcore==1.0.5
httptools==0.6.1
Expand Down
45 changes: 45 additions & 0 deletions test_case1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from aiosmtplib import send
from email.message import EmailMessage

app = FastAPI()

# Email configuration
EMAIL = "[email protected]"
PASSWORD = "j*orWasSatc^TrdT7k7BGZ#"
SMTP_HOST = "work.timbu.cloud"
SMTP_PORT = 465

# Define a Pydantic model for the request body
class EmailRequest(BaseModel):
to_email: EmailStr
subject: str = "Test Email"
body: str = "This is a test email from FastAPI"



@app.post("/send-tinbu-mail")
async def send_email(email_request: EmailRequest):
# Create the email message
message = EmailMessage()
message["From"] = EMAIL
message["To"] = email_request.to_email
message["Subject"] = email_request.subject
message.set_content(email_request.body)

# SMTP configuration
smtp_settings = {
"hostname": SMTP_HOST,
"port": SMTP_PORT,
"username": EMAIL,
"password": PASSWORD,
"use_tls": True, # Use SSL/TLS for secure connection
}

try:
# Send the email
await send(message, **smtp_settings)
return {"message": f"Email sent to {email_request.to_email} successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send email: {str(e)}")
Loading

0 comments on commit fd78c63

Please sign in to comment.