diff --git a/backend/src/auth/dependencies.py b/backend/src/auth/dependencies.py index 90ad8e91..0455f7ad 100644 --- a/backend/src/auth/dependencies.py +++ b/backend/src/auth/dependencies.py @@ -1,3 +1,4 @@ +from http import HTTPStatus from typing import Annotated, Optional from fastapi.security import OAuth2PasswordBearer from passlib.context import CryptContext @@ -56,9 +57,15 @@ def authenticate_user(email: str, password: str): ) ).first() if not user: - return False + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail={"error": "Invalid username.", "error_id": "1"}, + ) if not verify_password(password, user.hashed_password): - return False + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail={"error": "Incorrect password.", "error_id": "2"}, + ) return user diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 794d72df..bda15b6d 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -7,7 +7,7 @@ from fastapi import BackgroundTasks, Depends, APIRouter, HTTPException, Response from fastapi.security import OAuth2PasswordRequestForm import httpx -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.orm import selectinload from src.auth.utils import ( create_token, @@ -103,12 +103,10 @@ def sign_up( def log_in( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response ) -> Token: - user = authenticate_user(form_data.username, form_data.password) - if not user: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="Incorrect username or password.", - ) + try: + user = authenticate_user(form_data.username, form_data.password) + except HTTPException as exc: + raise exc return create_token(user, response) @@ -285,6 +283,68 @@ def complete_password_reset( session.commit() +####################### +# Reset password # +####################### +@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: + print( + f"""ERROR: Attempt to reset password for email {email} that doesn't match any existing user""" + ) + raise HTTPException(HTTPStatus.NOT_FOUND) + + # Mark all existing password reset codes for the given user as used to prevent usage of old codes + session.execute( + update(PasswordReset).where(PasswordReset.user_id == user.id).values(used=True) + ) + session.commit() + + 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: + print( + "ERROR: Attempt to use password reset code that wasn't previously generated by Jippy" + ) + raise HTTPException(HTTPStatus.NOT_FOUND) + + if password_reset.used: + print("ERROR: Attempt to use password reset code that has already been used") + raise (HTTPException(HTTPStatus.CONFLICT)) + + 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() + + @routerWithAuth.get("/session") def get_user( current_user: Annotated[User, Depends(get_current_user)], diff --git a/frontend/app/(unauthenticated)/login/page.tsx b/frontend/app/(unauthenticated)/login/page.tsx index 490ffef5..a4763175 100644 --- a/frontend/app/(unauthenticated)/login/page.tsx +++ b/frontend/app/(unauthenticated)/login/page.tsx @@ -99,7 +99,7 @@ function LoginPage() { Your email or password is incorrect. Please try again, or{" "} - + reset your password . diff --git a/frontend/app/(unauthenticated)/register/page.tsx b/frontend/app/(unauthenticated)/register/page.tsx index 01a92f1f..45ef1acc 100644 --- a/frontend/app/(unauthenticated)/register/page.tsx +++ b/frontend/app/(unauthenticated)/register/page.tsx @@ -86,8 +86,13 @@ function RegisterPage() { {/* Body */} {isError && ( - - + +
+ +
This email is already registered.{" "} diff --git a/frontend/app/(unauthenticated)/reset-password/_components/reset-password-complete-form.tsx b/frontend/app/(unauthenticated)/reset-password/_components/reset-password-complete-form.tsx index 9c91e822..6a7e6e3c 100644 --- a/frontend/app/(unauthenticated)/reset-password/_components/reset-password-complete-form.tsx +++ b/frontend/app/(unauthenticated)/reset-password/_components/reset-password-complete-form.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { HttpStatusCode } from "axios"; import { CircleAlert } from "lucide-react"; import { z } from "zod"; @@ -54,6 +55,9 @@ export default function ResetPasswordCompleteForm({ onComplete: () => void; }) { const [isError, setIsError] = useState(false); + const [errorMsg, setErrorMsg] = useState( + "Your password needs to match.", + ); const form = useForm({ resolver: zodResolver(resetPasswordCompleteFormSchema), @@ -74,6 +78,17 @@ export default function ResetPasswordCompleteForm({ if (response.error) { setIsError(true); + if (response.status === HttpStatusCode.Conflict) { + setErrorMsg( + "Password reset link has expired. Please check your email for the latest link", + ); + } else if (response.status === HttpStatusCode.NotFound) { + setErrorMsg( + "Invalid password reset link. Please only click on links sent by us", + ); + } else { + setErrorMsg("Error while resetting your password. Please try again"); + } } else { setIsError(false); onComplete(); @@ -91,11 +106,14 @@ export default function ResetPasswordCompleteForm({ {isError && ( - - - - Your password needs to match. - + +
+ +
+ {errorMsg}
)}
diff --git a/frontend/components/form/inputs/password-input.tsx b/frontend/components/form/inputs/password-input.tsx index 2a156f80..f2b3a4f0 100644 --- a/frontend/components/form/inputs/password-input.tsx +++ b/frontend/components/form/inputs/password-input.tsx @@ -47,7 +47,7 @@ const PasswordInput = forwardRef( {showForgetPassword && ( - + Forgot password? )}