Skip to content

Commit

Permalink
Merge pull request #37 from cs3216-a3-group-4/seeleng/add-password-reset
Browse files Browse the repository at this point in the history
feat: add password routes
  • Loading branch information
seelengxd authored Sep 24, 2024
2 parents 3493c9f + 9108fbf commit 3f6ecc9
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Add user password reset table
Revision ID: 3c473e77f83a
Revises: a31eae0cbe7a
Create Date: 2024-09-24 16:29:28.507257
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "3c473e77f83a"
down_revision: Union[str, None] = "a31eae0cbe7a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"password-reset",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(), nullable=False),
sa.Column("used", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("password-reset")
# ### end Alembic commands ###
9 changes: 9 additions & 0 deletions backend/src/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@ class User(Base):

categories: Mapped[list[Category]] = relationship(secondary=user_category_table)
notes: Mapped[list[Note]] = relationship("Note", backref="user")


class PasswordReset(Base):
__tablename__ = "password-reset"

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
code: Mapped[str]
used: Mapped[bool]
72 changes: 66 additions & 6 deletions backend/src/auth/router.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
from http import HTTPStatus
from typing import Annotated
from uuid import uuid4


from fastapi import Depends, APIRouter, HTTPException, Response
from fastapi import BackgroundTasks, Depends, APIRouter, HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
import httpx
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.auth.utils import create_token
from src.auth.utils import create_token, send_reset_password_email
from src.common.constants import (
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
)
from src.auth.schemas import Token
from src.common.dependencies import get_session
from .schemas import SignUpData, UserPublic
from .schemas import (
PasswordResetCompleteData,
PasswordResetRequestData,
SignUpData,
UserPublic,
)

from src.auth.dependencies import (
authenticate_user,
get_current_user,
get_password_hash,
)
from .models import AccountType, User
from .models import AccountType, PasswordReset, User

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

Expand Down Expand Up @@ -124,11 +130,65 @@ def auth_google(


@router.get("/session")
def get_user(staff: Annotated[User, Depends(get_current_user)]) -> UserPublic:
return staff
def get_user(user: Annotated[User, Depends(get_current_user)]) -> UserPublic:
return user


@router.get("/logout")
def logout(response: Response):
response.delete_cookie(key="session")
return ""


@router.post("/password-reset")
def request_password_reset(
data: PasswordResetRequestData,
background_task: BackgroundTasks,
session=Depends(get_session),
):
email = data.email
user = session.scalars(
select(User)
.where(User.email == email)
.where(User.account_type == AccountType.NORMAL)
).first()
if not user:
return

code = str(uuid4())
password_reset = PasswordReset(user_id=user.id, code=code, used=False)
session.add(password_reset)
session.commit()
background_task.add_task(send_reset_password_email, email, code)


@router.put("/password-reset")
def complete_password_reset(
code: str,
data: PasswordResetCompleteData,
session=Depends(get_session),
):
# 9b90a1bd-ccab-4dcb-93c9-9ef2367dbcc4
password_reset = session.scalars(
select(PasswordReset).where(PasswordReset.code == code)
).first()
if not password_reset or password_reset.used:
raise HTTPException(HTTPStatus.NOT_FOUND)

user = session.get(User, password_reset.user_id)
user.hashed_password = get_password_hash(data.password)
password_reset.used = True
session.add(user)
session.add(password_reset)
session.commit()


@router.put("/change-password")
def change_password(
user: Annotated[User, Depends(get_current_user)],
data: PasswordResetCompleteData,
session=Depends(get_session),
):
user.hashed_password = get_password_hash(data.password)
session.add(user)
session.commit()
19 changes: 18 additions & 1 deletion backend/src/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
from src.categories.schemas import CategoryDTO


Expand All @@ -20,3 +20,20 @@ class Token(BaseModel):
class SignUpData(BaseModel):
email: EmailStr
password: str = Field(min_length=6)


class PasswordResetRequestData(BaseModel):
email: EmailStr


class PasswordResetCompleteData(BaseModel):
password: str = Field(min_length=6)
confirm_password: str = Field(min_length=6)

@model_validator(mode="after")
def check_passwords_match(self):
pw1 = self.password
pw2 = self.confirm_password
if pw1 is not None and pw2 is not None and pw1 != pw2:
raise ValueError("passwords do not match")
return self
10 changes: 10 additions & 0 deletions backend/src/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from src.auth.dependencies import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token
from src.auth.models import User
from src.auth.schemas import Token, UserPublic
from src.common.constants import FRONTEND_URL
from src.utils.mail import send_email


def create_token(user: User, response: Response):
Expand All @@ -18,3 +20,11 @@ def create_token(user: User, response: Response):
token_type="bearer",
user=UserPublic.model_validate(user),
)


def send_reset_password_email(email: str, code: str):
send_email(
email,
"Reset your password",
f"Here is the link to reset your password.\n{FRONTEND_URL}/reset-password?code={code}",
)
3 changes: 3 additions & 0 deletions backend/src/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ def _get_env_var(name: str, default: str | None = None, required: bool = True):

# for scrapers
GUARDIAN_API_KEY: str = _get_env_var("GUARDIAN_API_KEY", required=False)

GOOGLE_EMAIL: str = _get_env_var("GOOGLE_EMAIL")
GOOGLE_APP_PASSWORD: str = _get_env_var("GOOGLE_APP_PASSWORD")
1 change: 0 additions & 1 deletion backend/src/events/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ def get_events(
event_query = event_query.order_by(Event.rating.desc(), Event.date.desc())

events = list(session.scalars(event_query))
print(events[0].reads)
return EventIndexResponse(total_count=total_count, count=len(events), data=events)


Expand Down
25 changes: 25 additions & 0 deletions backend/src/utils/mail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import smtplib
import ssl
from email.message import EmailMessage

from src.common.constants import GOOGLE_APP_PASSWORD, GOOGLE_EMAIL

PORT = 465 # For SSL


def send_email(
receiving_email_addr: str,
subject: str,
message: str,
):
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.gmail.com", PORT, context=context) as server:
server.login(GOOGLE_EMAIL, GOOGLE_APP_PASSWORD)

msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = GOOGLE_EMAIL
msg["To"] = receiving_email_addr
msg.set_content(message)

server.sendmail(GOOGLE_EMAIL, receiving_email_addr, msg.as_string())

0 comments on commit 3f6ecc9

Please sign in to comment.