Skip to content

Commit

Permalink
add functionality for forgot password requests
Browse files Browse the repository at this point in the history
  • Loading branch information
liberty-rising committed Dec 27, 2023
1 parent dd1d56c commit a439fd0
Show file tree
Hide file tree
Showing 13 changed files with 326 additions and 14 deletions.
6 changes: 6 additions & 0 deletions backend/models/token.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from pydantic import BaseModel


Expand All @@ -8,3 +9,8 @@ class Token(BaseModel):

class TokenData(BaseModel):
username: str


class ResetTokenData(BaseModel):
username: str
exp: datetime
24 changes: 23 additions & 1 deletion backend/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class UserUpdate(BaseModel):
requires_password_update: Optional[bool] = False


class ChangePassword(BaseModel):
class ChangePasswordRequest(BaseModel):
old_password: str = Field(...)
new_password: str = Field(...)

Expand All @@ -130,3 +130,25 @@ def validate_new_password(cls, v):
if not re.search(r"[A-Z]", v):
raise ValueError("Password should contain at least one uppercase letter")
return v


class ForgotPasswordRequest(BaseModel):
email: EmailStr


class ResetPasswordRequest(BaseModel):
token: str = Field(...)
new_password: str = Field(...)
confirm_password: str = Field(...)

@validator("new_password")
def validate_new_password(cls, v):
if len(v) < 8:
raise ValueError("Password should have at least 8 characters")
if not re.search(r"\d", v):
raise ValueError("Password should contain at least one digit")
if not re.search(r"\W", v):
raise ValueError("Password should contain at least one symbol")
if not re.search(r"[A-Z]", v):
raise ValueError("Password should contain at least one uppercase letter")
return v
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ pydantic[email]==2.4.2
langchain==0.0.351
pytest==6.2.5
pytest-asyncio==0.15.1
sendgrid==6.11.0
69 changes: 63 additions & 6 deletions backend/routes/user_routes.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException

from models.user import (
ChangePasswordRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
User,
UserOut,
UserRole,
UserUpdate,
)
from databases.database_manager import DatabaseManager
from databases.user_manager import UserManager
from security import (
decode_reset_token,
generate_password_reset_token,
get_current_admin_user,
get_current_user,
get_password_hash,
verify_password,
)

from models.user import ChangePassword, User, UserOut, UserRole, UserUpdate
from databases.database_manager import DatabaseManager
from databases.user_manager import UserManager
from utils.email import send_password_reset_email

user_router = APIRouter()

Expand Down Expand Up @@ -63,7 +74,8 @@ async def update_user(

@user_router.put("/users/change-password/")
async def change_user_password(
change_password: ChangePassword, current_user: User = Depends(get_current_user)
change_password: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
):
# Verify old password
if not verify_password(change_password.old_password, current_user.hashed_password):
Expand All @@ -85,3 +97,48 @@ async def change_user_password(
return {
"message": f"Successfully updated password for {updated_user.username}."
}


@user_router.post("/users/forgot-password/")
async def forgot_password(
request: ForgotPasswordRequest, background_tasks: BackgroundTasks
):
# Get user by email
with DatabaseManager() as session:
user_manager = UserManager(session)
user = user_manager.get_user_by_email(email=request.email)
if not user:
raise HTTPException(status_code=404, detail="User not found")

# Generate password reset token
token = generate_password_reset_token(user.username)

# Send password reset email
background_tasks.add_task(send_password_reset_email, [user.email], token)

return {"message": "Password reset email sent"}


@user_router.put("/users/reset-password/")
async def reset_password(request: ResetPasswordRequest):
token_data = decode_reset_token(request.token)

if request.new_password != request.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")

with DatabaseManager() as session:
user_manager = UserManager(session)

# Update user password
new_hashed_password = get_password_hash(request.new_password)
updated_user = user_manager.update_user_password(
username=token_data.username,
new_hashed_password=new_hashed_password,
)

if not updated_user:
raise HTTPException(status_code=404, detail="User not found")

return {
"message": f"Successfully updated password for {updated_user.username}."
}
37 changes: 35 additions & 2 deletions backend/security.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import Cookie, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
Expand All @@ -8,8 +8,9 @@

from databases.database_manager import DatabaseManager
from databases.user_manager import UserManager
from models.token import ResetTokenData
from models.user import User
from settings import JWT_SECRET_KEY
from settings import JWT_SECRET_KEY, PASSWORD_RESET_EXPIRE_MINUTES


# Configuration for JWT token
Expand Down Expand Up @@ -222,3 +223,35 @@ def update_user_refresh_token(
with DatabaseManager() as session:
manager = UserManager(session)
manager.update_refresh_token(user_id, refresh_token)


def generate_password_reset_token(username: str):
payload = {
"username": username,
"exp": datetime.utcnow() + timedelta(minutes=PASSWORD_RESET_EXPIRE_MINUTES),
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256")


def decode_reset_token(token: str):
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("username")
exp = payload.get("exp")
if username is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
# Check if the token has expired
if datetime.now(timezone.utc) > datetime.fromtimestamp(exp, tz=timezone.utc):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Token has expired"
)

return ResetTokenData(username=username, exp=exp)
except JWTError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
5 changes: 5 additions & 0 deletions backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from decouple import config

APP_ENV = config("APP_ENV")
APP_HOST = config("APP_HOST")

ACCESS_TOKEN_EXPIRE_MINUTES = config("ACCESS_TOKEN_EXPIRE_MINUTES", default=30)
REFRESH_TOKEN_EXPIRE_DAYS = config("REFRESH_TOKEN_EXPIRE_DAYS", default=7)
Expand All @@ -20,3 +21,7 @@
DB_URL = config("DB_URL")

OPENAI_API_KEY = config("OPENAI_API_KEY")

PASSWORD_RESET_EXPIRE_MINUTES = int(config("PASSWORD_RESET_EXPIRE_MINUTES", default=15))

SENDGRID_API_KEY = config("SENDGRID_API_KEY")
26 changes: 26 additions & 0 deletions backend/utils/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, TrackingSettings, ClickTracking
from typing import List

from settings import APP_ENV, APP_HOST, SENDGRID_API_KEY


async def send_password_reset_email(email: List[str], token: str):
message = Mail(
from_email="[email protected]",
to_emails=email,
subject="DocShow AI - Password Reset Request",
html_content=f'Click on the link to reset your password: <a href="https://{APP_HOST}/reset-password?token={token}">https://{APP_HOST}/reset-password</a>',
)
# Disable click tracking in development
if APP_ENV == "development":
message.tracking_settings = TrackingSettings()
message.tracking_settings.click_tracking = ClickTracking(False, False)
try:
sg = SendGridAPIClient(SENDGRID_API_KEY)
response = await sg.send(message)
print(response.status_code)
print(response.body)
print(response.headers)
except Exception as e:
print(str(e))
8 changes: 6 additions & 2 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ import BlogPage from './pages/blog/BlogPage';
import ChangePasswordPage from './pages/change-password/ChangePasswordPage';
import CreateChartPage from './pages/charts/CreateChartPage';
import CreateDashboardPage from './pages/dashboards/CreateDashboard';
import CreateDataProfile from './pages/data-profiling/CreateDataProfile';
import DashboardMenuPage from './pages/dashboards/DashboardsMenuPage';
import Dashboard from './pages/dashboards/Dashboard';
import DataProfilingPage from './pages/data-profiling/DataProfilingPage';
import CreateDataProfile from './pages/data-profiling/CreateDataProfile';
import SpecificDataProfilePage from './pages/data-profiling/SpecificDataProfilePage';
import ForgotPasswordPage from './pages/forgot-password/forgotPasswordPage';
import LandingPage from './pages/landing/LandingPage';
import LoginPage from './pages/login/LoginPage';
import PricingPage from './pages/pricing/PricingPage';
import RegisterPage from './pages/register/RegisterPage';
import ResetPasswordPage from './pages/reset-password/ResetPasswordPage';
import SpecificDataProfilePage from './pages/data-profiling/SpecificDataProfilePage';
import UploadPage from './pages/upload/UploadPage';
import UserPage from './pages/user/UserPage';
import Logout from './pages/logout/LogoutPage';
Expand Down Expand Up @@ -62,6 +64,8 @@ function App() {
<LandingLayout><LoginPage /></LandingLayout>}
/>
<Route path="/change-password" element={<RequireAuth><LandingLayout><ChangePasswordPage /></LandingLayout></RequireAuth>} />
<Route path="/reset-password" element={<LandingLayout><ResetPasswordPage /></LandingLayout>} />
<Route path="/forgot-password" element={<LandingLayout><ForgotPasswordPage /></LandingLayout>} />
<Route path="/register" element={<LandingLayout><RegisterPage /></LandingLayout>} />
<Route path="/pricing" element={<LandingLayout><PricingPage /></LandingLayout>} />
<Route path="/blog" element={<LandingLayout><BlogPage /></LandingLayout>} />
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/api/axiosInterceptor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ axios.interceptors.response.use(response => response, async (error) => {
&& error.response.status === 401
&& !originalRequest._retry
&& !originalRequest.url.includes('refresh-token/') // Check if the failed request is not for the refresh-token endpoint
&& !originalRequest.url.includes('token/')) { // Exclude the login endpoint
&& !originalRequest.url.includes('token/') // Exclude the login endpoint
&& !originalRequest.url.includes('forgot-password/')) { // Exclude the forgot-password endpoint
originalRequest._retry = true;
try {
// Attempt to refresh the token
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/change-password/ChangePasswordPage.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom';
import { Box, Container, Typography } from '@mui/material'
import { Box, Container } from '@mui/material'
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import ChangePassword from '../../components/change-password/ChangePassword';
import { updateUserPassword } from '../../utils/updateUserPassword';
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/pages/forgot-password/ForgotPasswordPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { Box, Button, TextField, Typography } from '@mui/material';
import Alert from '@mui/material/Alert';
import axios from 'axios';
import validator from 'validator';
import { API_URL } from '../../utils/constants';

function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [errorMessage, setErrorMessage] = useState('');

const handleSubmit = async (event) => {
event.preventDefault();

// Validate email
if (!validator.isEmail(email)) {
setErrorMessage('Please enter a valid email address.');
return;
}

// Send request to backend server
try {
await axios.post(`${API_URL}users/forgot-password/`, { email });
alert('Password reset email sent!');
} catch (error) {
setErrorMessage('Error sending password reset email. Please try again.');
}
};

return (
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Forgot Password
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Reset Password
</Button>
</Box>
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
</Box>
);
}

export default ForgotPasswordPage;
2 changes: 1 addition & 1 deletion frontend/src/pages/login/LoginPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function LoginPage({ onLogin }) {
Sign In
</Button>
<Typography align="center" sx={{ mt: 2 }}>
<Button onClick={() => {/* logic to handle forgot password */}}>
<Button onClick={() => navigate('/forgot-password')}>
Forgot password?
</Button>
<Button onClick={handleRegister}>
Expand Down
Loading

0 comments on commit a439fd0

Please sign in to comment.