Skip to content

Commit

Permalink
Merge pull request hotosm#224 from hotosm/develop
Browse files Browse the repository at this point in the history
Production Release
  • Loading branch information
nrjadkry authored Sep 13, 2024
2 parents 11a14ce + c339752 commit e0f3a15
Show file tree
Hide file tree
Showing 23 changed files with 428 additions and 599 deletions.
2 changes: 1 addition & 1 deletion src/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any:
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 1 # 1 day
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 8 # 8 day

RESET_PASSWORD_TOKEN_EXPIRE_MINUTES: int = 5
GOOGLE_CLIENT_ID: str
GOOGLE_CLIENT_SECRET: str
GOOGLE_LOGIN_REDIRECT_URI: str = "http://localhost:8000"
Expand Down
87 changes: 87 additions & 0 deletions src/backend/app/email_templates/password/password_reset.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.email-container {
max-width: 600px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin: 20px;
}
.header {
background-color: #d73f3f;
color: #ffffff;
text-align: center;
padding: 25px;
border-radius: 10px 10px 0 0;
}
.header h1 {
margin: 0;
font-size: 26px;
}
.content {
padding: 25px;
}
.content p {
font-size: 16px;
line-height: 1.5;
color: #333333;
}
.reset-button {
display: inline-block;
margin-top: 20px;
padding: 12px 24px;
background-color: #d73f3f;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s ease;
}
.reset-button:hover {
background-color: #a33030;
}
.footer {
background-color: #f4f4f4;
text-align: center;
padding: 20px;
color: #666666;
font-size: 14px;
border-radius: 0 0 10px 10px;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>Password Reset Request</h1>
</div>
<div class="content">
<p>Hi {{email}},</p>
<p>
We received a request to reset your password. Please click the button
below to reset it:
</p>
<a href="{{reset_link}}" class="reset-button">Reset Password</a>
<p>If you didn’t request this, please ignore this email.</p>
<p>Thank you,<br />The {{project_name}} Team</p>
</div>
<div class="footer">
<p>Thank you for using Drone Tasking Manager</p>
</div>
</div>
</body>
</html>
1 change: 0 additions & 1 deletion src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from psycopg import Connection
from shapely.geometry import shape, mapping
from shapely.ops import unary_union

from app.projects import project_schemas, project_deps, project_logic
from app.db import database
from app.models.enums import HTTPStatus
Expand Down
3 changes: 3 additions & 0 deletions src/backend/app/tasks/task_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ async def new_event(
db, project["author_id"]
)
html_content = render_email_template(
folder_name="mapping",
template_name="requests.html",
context={
"name": author["name"],
Expand Down Expand Up @@ -240,6 +241,7 @@ async def new_event(
db, requested_user_id
)
html_content = render_email_template(
folder_name="mapping",
template_name="approved_or_rejected.html",
context={
"email_subject": "Mapping Request Approved",
Expand Down Expand Up @@ -286,6 +288,7 @@ async def new_event(
db, requested_user_id
)
html_content = render_email_template(
folder_name="mapping",
template_name="approved_or_rejected.html",
context={
"email_subject": "Mapping Request Rejected",
Expand Down
13 changes: 13 additions & 0 deletions src/backend/app/users/user_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from app.users.auth import Auth
from app.users.user_schemas import AuthUser
from loguru import logger as log
from datetime import datetime, timedelta
import jwt


async def init_google_auth():
Expand Down Expand Up @@ -50,3 +52,14 @@ async def login_required(
raise HTTPException(status_code=401, detail="Access token not valid") from e

return AuthUser(**user)


def create_reset_password_token(email: str):
expire = datetime.now() + timedelta(
minutes=settings.RESET_PASSWORD_TOKEN_EXPIRE_MINUTES
)
to_encode = {"sub": email, "exp": expire}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
86 changes: 79 additions & 7 deletions src/backend/app/users/user_routes.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import os
import jwt
from app.users import user_schemas
from app.users import user_deps
from app.users import user_logic
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi import APIRouter, HTTPException, Depends, Request, BackgroundTasks
from typing import Annotated
from fastapi.security import OAuth2PasswordRequestForm
from app.users.user_schemas import (
DbUser,
Token,
UserProfileIn,
AuthUser,
Expand All @@ -17,6 +19,8 @@
from psycopg import Connection
from fastapi.responses import JSONResponse
from loguru import logger as log
from pydantic import EmailStr
from app.utils import send_reset_password_email


if settings.DEBUG:
Expand All @@ -38,18 +42,18 @@ async def login_access_token(
"""
OAuth2 compatible token login, get an access token for future requests
"""
user = await user_deps.authenticate(db, form_data.username, form_data.password)
user = await user_logic.authenticate(db, form_data.username, form_data.password)

if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not user.is_active:
elif not user.get("is_active"):
raise HTTPException(status_code=400, detail="Inactive user")

user_info = {
"id": user.id,
"email": user.email_address,
"name": user.name,
"profile_img": user.profile_img,
"id": user.get("id"),
"email": user.get("email_address"),
"name": user.get("name"),
"profile_img": user.get("profile_img"),
}

access_token, refresh_token = await user_logic.create_access_token(user_info)
Expand Down Expand Up @@ -160,3 +164,71 @@ async def my_data(
user_info_dict.update(has_user_profile.model_dump())

return user_info_dict


@router.post("/forgot-password/")
async def forgot_password(
db: Annotated[Connection, Depends(database.get_db)],
email: EmailStr,
background_tasks: BackgroundTasks,
):
user = await DbUser.get_user_by_email(db, email)
token = user_deps.create_reset_password_token(user["email_address"])
# Store the token in the database (or other storage mechanism) FIXME it is necessary to save reset password token.
# user["reset_password_token"] = token
background_tasks.add_task(send_reset_password_email, user["email_address"], token)

return JSONResponse(
content={"detail": "Password reset email sent"}, status_code=200
)


@router.post("/reset-password/")
async def reset_password(
db: Annotated[Connection, Depends(database.get_db)], token: str, new_password: str
):
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
email = payload.get("sub")
if email is None:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid token"
)

user = await DbUser.get_user_by_email(db, email)
if not user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User not found"
)

# Update password within a transaction
async with db.transaction():
async with db.cursor() as cur:
await cur.execute(
"""
UPDATE users
SET password = %(password)s
WHERE id = %(user_id)s;
""",
{
"password": user_logic.get_password_hash(new_password),
"user_id": user.get("id"),
},
)

except jwt.ExpiredSignatureError:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Token expired")
except jwt.JWTError:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid token")
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"An error occurred: {str(e)}",
)

return JSONResponse(
content={"detail": "Your password has been successfully reset!"},
status_code=200,
)
23 changes: 17 additions & 6 deletions src/backend/app/users/user_schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import uuid
from app.models.enums import State, UserRole
from app.models.enums import HTTPStatus, State, UserRole
from pydantic import BaseModel, EmailStr, ValidationInfo, Field
from pydantic.functional_validators import field_validator
from typing import Optional
Expand Down Expand Up @@ -253,12 +253,23 @@ async def get_user_by_id(db: Connection, id: str) -> dict[str, Any] | None:
return result if result else None

@staticmethod
async def get_user_by_email(db: Connection, email: str) -> dict[str, Any] | None:
async def get_user_by_email(db: Connection, email: str) -> dict[str, Any]:
query = "SELECT * FROM users WHERE email_address = %s LIMIT 1;"
async with db.cursor() as cur:
await cur.execute(query, (email,))
result = await cur.fetchone()
return result if result else None
try:
async with db.cursor(row_factory=dict_row) as cur:
await cur.execute(query, (email,))
result = await cur.fetchone()
if result is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="User with this email does not exist",
)
return result
except Exception:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="An error occurred while querying the database.",
)

@staticmethod
async def get_requested_user_id(
Expand Down
57 changes: 41 additions & 16 deletions src/backend/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,31 +367,24 @@ class EmailData:
subject: str


def render_email_template(template_name: str, context: dict[str, Any]) -> str:
def render_email_template(
folder_name: str, template_name: str, context: dict[str, Any]
) -> str:
"""
Render an email template with the given context.
Render an email template with the given context from the specified folder.
Args:
folder_name (str): The folder containing the template file.
template_name (str): The name of the template file to be rendered.
context (dict[str, Any]): A dictionary containing the context variables to be used in the template.
Returns:
str: The rendered HTML content of the email template.
Example:
html_content = render_email_template(
template_name="welcome_email.html",
context={"username": "John Doe", "welcome_message": "Welcome to our service!"}
)
This function reads the specified email template from the 'email-templates' directory,
then uses the `Template` class from the `jinja2` library to render the template with
the provided context variables.
"""

template_str = (
Path(__file__).parent / "email_templates" / "mapping" / template_name
).read_text()
template_path = (
Path(__file__).parent / "email_templates" / folder_name / template_name
)
template_str = template_path.read_text()
html_content = Template(template_str).render(context)
return html_content

Expand Down Expand Up @@ -438,10 +431,42 @@ async def send_notification_email(email_to, subject, html_content):

def test_email(email_to: str, subject: str = "Test email") -> None:
html_content = render_email_template(
folder_name="mapping",
template_name="email_template.html",
context={"project_name": settings.APP_NAME, "email": email_to},
)

send_notification_email(
email_to=email_to, subject=subject, html_content=html_content
)


async def send_reset_password_email(email: str, token: str):
reset_link = f"{settings.FRONTEND_URL}/reset-password?token={token}"

context = {
"reset_link": reset_link,
"project_name": settings.EMAILS_FROM_NAME,
"email": email,
}

html_content = render_email_template("password", "password_reset.html", context)

message = MIMEText(html_content, "html")
message["From"] = formataddr(
(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL)
)
message["To"] = email
message["Subject"] = "Password Reset Request"

try:
log.debug("Sending email message")
await send_email(
message,
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
username=settings.SMTP_USER,
password=settings.SMTP_PASSWORD,
)
except Exception as e:
log.error(f"Error sending email: {e}")
Loading

0 comments on commit e0f3a15

Please sign in to comment.