From f23a20cc272e6bbc691189a69cadad99a584721f Mon Sep 17 00:00:00 2001 From: liberty-rising Date: Sun, 24 Dec 2023 00:34:01 +0100 Subject: [PATCH 1/2] add custom oauth request --- backend/models/auth.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/models/auth.py diff --git a/backend/models/auth.py b/backend/models/auth.py new file mode 100644 index 0000000..2986799 --- /dev/null +++ b/backend/models/auth.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional + + +class CustomOAuth2PasswordRequestForm(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + password: str + + class Config: + json_schema_extra = { + "example": { + "email": "user@example.com", + "username": None, + "password": "secret", + } + } From fb4d4940c6c96061e1e562253b623421c05f8c75 Mon Sep 17 00:00:00 2001 From: liberty-rising Date: Sun, 24 Dec 2023 00:34:28 +0100 Subject: [PATCH 2/2] fix login issue, add capability to login with username or email --- backend/databases/user_manager.py | 14 +++++++- .../initialization/setup_dev_environment.py | 2 +- backend/routes/auth_routes.py | 22 +++++++++---- backend/security.py | 19 ++++++++--- frontend/package-lock.json | 11 ++++++- frontend/package.json | 3 +- frontend/src/api/axiosInterceptor.jsx | 3 +- frontend/src/pages/Login.jsx | 33 ++++++++++++------- frontend/src/pages/Logout.jsx | 1 + 9 files changed, 81 insertions(+), 27 deletions(-) diff --git a/backend/databases/user_manager.py b/backend/databases/user_manager.py index 4fb74b4..2ff741d 100644 --- a/backend/databases/user_manager.py +++ b/backend/databases/user_manager.py @@ -25,7 +25,19 @@ def __init__(self, session: Session): """ self.db_session = session - def get_user(self, username: str): + def get_user_by_email(self, email: str) -> User: + """ + Get a user based on their email. + + Args: + email (str): The email of the user. + + Returns: + User: The User object if found, else None. + """ + return self.db_session.query(User).filter(User.email == email).first() + + def get_user_by_username(self, username: str) -> User: """ Get a user based on their username. diff --git a/backend/envs/dev/initialization/setup_dev_environment.py b/backend/envs/dev/initialization/setup_dev_environment.py index 505f331..407e357 100644 --- a/backend/envs/dev/initialization/setup_dev_environment.py +++ b/backend/envs/dev/initialization/setup_dev_environment.py @@ -41,7 +41,7 @@ def create_admin_user(): with DatabaseManager() as session: user_manager = UserManager(session) - existing_user = user_manager.get_user(admin_user.username) + existing_user = user_manager.get_user_by_username(admin_user.username) if not existing_user: user_manager.create_user(admin_user) logger.debug("Admin user created.") diff --git a/backend/routes/auth_routes.py b/backend/routes/auth_routes.py index e08a6e6..5563e1d 100644 --- a/backend/routes/auth_routes.py +++ b/backend/routes/auth_routes.py @@ -4,12 +4,13 @@ the security module for creating JWT tokens and password hashing/verification. """ from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, Response, status -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import BaseModel +from fastapi import APIRouter, Depends, Form, HTTPException, Response, status +from pydantic import BaseModel, EmailStr +from typing import Optional from databases.database_manager import DatabaseManager from databases.user_manager import UserManager +from models.auth import CustomOAuth2PasswordRequestForm from models.user import User, UserCreate from security import ( authenticate_user, @@ -40,7 +41,10 @@ class LogoutResponse(BaseModel): @auth_router.post("/token/", response_model=LoginResponse) async def login_for_access_token( - response: Response, form_data: OAuth2PasswordRequestForm = Depends() + response: Response, + username: Optional[str] = Form(None), + email: Optional[EmailStr] = Form(None), + password: str = Form(...), ): """ Authenticate a user and set a JWT token in a cookie upon successful authentication. @@ -49,12 +53,16 @@ async def login_for_access_token( it creates a JWT token and sets it in a secure, HttpOnly cookie in the response. Args: - form_data (OAuth2PasswordRequestForm): Contains the user's provided username and password. + form_data (CustomOAuth2PasswordRequestForm): Contains the user's provided username/email and password. Returns: dict: A success message indicating successful authentication. The JWT token is not returned in the response body but is set in a secure cookie. """ - user = authenticate_user(form_data.username, form_data.password) + form_data = CustomOAuth2PasswordRequestForm( + username=username, email=email, password=password + ) + print("form_data", form_data) + user = authenticate_user(form_data.username, form_data.email, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -154,7 +162,7 @@ async def register(response: Response, user: UserCreate): user_manager = UserManager(session) # Ensure unique username and email - existing_user = user_manager.get_user(username=user.username) + existing_user = user_manager.get_user_by_username(username=user.username) if existing_user: raise HTTPException(status_code=400, detail="Username already registered") existing_email = user_manager.get_email(email=user.email) diff --git a/backend/security.py b/backend/security.py index d17354d..da8e0e2 100644 --- a/backend/security.py +++ b/backend/security.py @@ -3,6 +3,7 @@ from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError from passlib.context import CryptContext +from pydantic import EmailStr from typing import Optional from databases.database_manager import DatabaseManager @@ -21,12 +22,22 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -def authenticate_user(username: str, password: str) -> User: +def authenticate_user(username: str, email: EmailStr, password: str) -> User: with DatabaseManager() as session: manager = UserManager(session) - user = manager.get_user(username) + + if email: + print("email", email) + user = manager.get_user_by_email(email=email) + print("user", user) + elif username: + user = manager.get_user_by_username(username=username) + else: + return None + if user and verify_password(password, user.hashed_password): return user + return None @@ -67,7 +78,7 @@ def verify_refresh_token(refresh_token: str = Cookie(None)) -> User: # Find the user in the database with DatabaseManager() as session: manager = UserManager(session) - user = manager.get_user(username) + user = manager.get_user_by_username(username) if user is None or user.refresh_token != refresh_token: raise HTTPException( @@ -164,7 +175,7 @@ def get_current_user(request: Request) -> User: with DatabaseManager() as session: user_manager = UserManager(session) - user = user_manager.get_user(username=username) + user = user_manager.get_user_by_username(username=username) if user is None: raise HTTPException( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5f69fd3..065b0fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,8 @@ "qs": "^6.11.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.19.0" + "react-router-dom": "^6.19.0", + "validator": "^13.11.0" }, "devDependencies": { "@types/react": "^18.2.37", @@ -5093,6 +5094,14 @@ "punycode": "^2.1.0" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e4e4130..ca4784f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,8 @@ "qs": "^6.11.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.19.0" + "react-router-dom": "^6.19.0", + "validator": "^13.11.0" }, "devDependencies": { "@types/react": "^18.2.37", diff --git a/frontend/src/api/axiosInterceptor.jsx b/frontend/src/api/axiosInterceptor.jsx index f4af08d..37126b7 100644 --- a/frontend/src/api/axiosInterceptor.jsx +++ b/frontend/src/api/axiosInterceptor.jsx @@ -9,7 +9,8 @@ axios.interceptors.response.use(response => response, async (error) => { if (error.response && 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('refresh-token/') // Check if the failed request is not for the refresh-token endpoint + && !originalRequest.url.includes('token/')) { // Exclude the login endpoint originalRequest._retry = true; try { // Attempt to refresh the token diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 4da6936..b170e94 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,26 +2,31 @@ import React, { useState } from 'react'; import axios from 'axios'; import qs from 'qs'; import { Box, Button, Checkbox, Container, FormControlLabel, TextField, Typography } from '@mui/material'; +import Alert from '@mui/material/Alert'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import { useAuth } from '../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; +import validator from 'validator'; import { API_URL } from '../utils/constants'; function LoginPage({ onLogin }) { - const [email, setEmail] = useState(''); + const [usernameOrEmail, setUsernameOrEmail] = useState(''); const [password, setPassword] = useState(''); const [rememberMe, setRememberMe] = useState(false); const navigate = useNavigate(); const { updateAuth } = useAuth(); + const [errorMessage, setErrorMessage] = useState(''); const handleSubmit = async (event) => { event.preventDefault(); + // Determine if usernameOrEmail should be sent as username or email + const isEmail = validator.isEmail(usernameOrEmail); + console.log(isEmail) + const data = isEmail ? { email: usernameOrEmail, password } : { username: usernameOrEmail, password }; + try { - const response = await axios.post(`${API_URL}token/`, qs.stringify ({ - username: email, - password - }), { + const response = await axios.post(`${API_URL}token/`, qs.stringify (data), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } @@ -32,7 +37,12 @@ function LoginPage({ onLogin }) { navigate('/dashboards'); } } catch (error) { - console.error('Login error:', error); + if (error.response && error.response.status === 401) { + // Handle 401 error here + setErrorMessage('Invalid credentials'); + } else { + setErrorMessage(`Login error: ${error.message}`); + } } }; @@ -60,12 +70,12 @@ function LoginPage({ onLogin }) { required fullWidth id="email" - label="Email Address" - name="email" - autoComplete="email" + label="Username or Email" + name="usernameOrEmail" + autoComplete="username" autoFocus - value={email} - onChange={(e) => setEmail(e.target.value)} + value={usernameOrEmail} + onChange={(e) => setUsernameOrEmail(e.target.value)} /> setRememberMe(e.target.checked)} /> + {errorMessage && {errorMessage}}