From 3f0cfff9f1fcf49e43d4b8b776764969a2f6a7b8 Mon Sep 17 00:00:00 2001 From: mohamad Date: Wed, 14 May 2025 01:04:09 +0200 Subject: [PATCH] Refactor authentication endpoints and user management; update CORS settings and JWT handling for improved security and compatibility with FastAPI-Users. Remove deprecated user-related endpoints and streamline API structure. --- be/app/api/dependencies.py | 72 -------------- be/app/api/v1/api.py | 4 - be/app/api/v1/endpoints/auth.py | 136 -------------------------- be/app/api/v1/endpoints/costs.py | 6 +- be/app/api/v1/endpoints/financials.py | 24 ++--- be/app/api/v1/endpoints/groups.py | 16 +-- be/app/api/v1/endpoints/invites.py | 4 +- be/app/api/v1/endpoints/items.py | 12 +-- be/app/api/v1/endpoints/lists.py | 14 +-- be/app/api/v1/endpoints/ocr.py | 4 +- be/app/api/v1/endpoints/users.py | 30 ------ be/app/auth.py | 5 +- be/app/config.py | 24 ++--- be/app/core/security.py | 114 +-------------------- be/app/main.py | 14 +-- be/app/schemas/user.py | 10 ++ fe/src/config/api-config.ts | 23 +++-- fe/src/services/api.ts | 9 +- fe/src/stores/auth.ts | 53 +--------- 19 files changed, 87 insertions(+), 487 deletions(-) delete mode 100644 be/app/api/dependencies.py delete mode 100644 be/app/api/v1/endpoints/auth.py delete mode 100644 be/app/api/v1/endpoints/users.py diff --git a/be/app/api/dependencies.py b/be/app/api/dependencies.py deleted file mode 100644 index b0ad596..0000000 --- a/be/app/api/dependencies.py +++ /dev/null @@ -1,72 +0,0 @@ -# app/api/dependencies.py -import logging -from typing import Optional - -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.ext.asyncio import AsyncSession -from jose import JWTError - -from app.database import get_db -from app.core.security import verify_access_token -from app.crud import user as crud_user -from app.models import User as UserModel # Import the SQLAlchemy model -from app.config import settings - -logger = logging.getLogger(__name__) - -# Define the OAuth2 scheme -# tokenUrl should point to your login endpoint relative to the base path -# It's used by Swagger UI for the "Authorize" button flow. -oauth2_scheme = OAuth2PasswordBearer(tokenUrl=settings.OAUTH2_TOKEN_URL) - -async def get_current_user( - token: str = Depends(oauth2_scheme), - db: AsyncSession = Depends(get_db) -) -> UserModel: - """ - Dependency to get the current user based on the JWT token. - - - Extracts token using OAuth2PasswordBearer. - - Verifies the token (signature, expiry). - - Fetches the user from the database based on the token's subject (email). - - Raises HTTPException 401 if any step fails. - - Returns: - The authenticated user's database model instance. - """ - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=settings.AUTH_CREDENTIALS_ERROR, - headers={settings.AUTH_HEADER_NAME: settings.AUTH_HEADER_PREFIX}, - ) - - payload = verify_access_token(token) - if payload is None: - logger.warning("Token verification failed (invalid, expired, or malformed).") - raise credentials_exception - - email: Optional[str] = payload.get("sub") - if email is None: - logger.error("Token payload missing 'sub' (subject/email).") - raise credentials_exception # Token is malformed - - # Fetch user from database - user = await crud_user.get_user_by_email(db, email=email) - if user is None: - logger.warning(f"User corresponding to token subject not found: {email}") - # Could happen if user deleted after token issuance - raise credentials_exception # Treat as invalid credentials - - logger.debug(f"Authenticated user retrieved: {user.email} (ID: {user.id})") - return user - -# Optional: Dependency for getting the *active* current user -# You might add an `is_active` flag to your User model later -# async def get_current_active_user( -# current_user: UserModel = Depends(get_current_user) -# ) -> UserModel: -# if not current_user.is_active: # Assuming an is_active attribute -# logger.warning(f"Authentication attempt by inactive user: {current_user.email}") -# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user") -# return current_user \ No newline at end of file diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index ac49730..c8e4b50 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -1,8 +1,6 @@ from fastapi import APIRouter from app.api.v1.endpoints import health -from app.api.v1.endpoints import auth -from app.api.v1.endpoints import users from app.api.v1.endpoints import groups from app.api.v1.endpoints import invites from app.api.v1.endpoints import lists @@ -14,8 +12,6 @@ from app.api.v1.endpoints import financials api_router_v1 = APIRouter() api_router_v1.include_router(health.router) -api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication"]) -api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"]) api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) diff --git a/be/app/api/v1/endpoints/auth.py b/be/app/api/v1/endpoints/auth.py deleted file mode 100644 index c9bd1e1..0000000 --- a/be/app/api/v1/endpoints/auth.py +++ /dev/null @@ -1,136 +0,0 @@ -# app/api/v1/endpoints/auth.py -import logging -from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.schemas.user import UserCreate, UserPublic -from app.schemas.auth import Token -from app.crud import user as crud_user -from app.core.security import ( - verify_password, - create_access_token, - create_refresh_token, - verify_refresh_token -) -from app.core.exceptions import ( - EmailAlreadyRegisteredError, - InvalidCredentialsError, - UserCreationError -) -from app.config import settings - -logger = logging.getLogger(__name__) -router = APIRouter() - -@router.post( - "/signup", - response_model=UserPublic, - status_code=201, - summary="Register New User", - description="Creates a new user account.", - tags=["Authentication"] -) -async def signup( - user_in: UserCreate, - db: AsyncSession = Depends(get_db) -): - """ - Handles user registration. - - Validates input data. - - Checks if email already exists. - - Hashes the password. - - Stores the new user in the database. - """ - logger.info(f"Signup attempt for email: {user_in.email}") - existing_user = await crud_user.get_user_by_email(db, email=user_in.email) - if existing_user: - logger.warning(f"Signup failed: Email already registered - {user_in.email}") - raise EmailAlreadyRegisteredError() - - try: - created_user = await crud_user.create_user(db=db, user_in=user_in) - logger.info(f"User created successfully: {created_user.email} (ID: {created_user.id})") - return created_user - except Exception as e: - logger.error(f"Error during user creation for {user_in.email}: {e}", exc_info=True) - raise UserCreationError() - -@router.post( - "/login", - response_model=Token, - summary="User Login", - description="Authenticates a user and returns an access and refresh token.", - tags=["Authentication"] -) -async def login( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - db: AsyncSession = Depends(get_db) -): - """ - Handles user login. - - Finds user by email (provided in 'username' field of form). - - Verifies the provided password against the stored hash. - - Generates and returns JWT access and refresh tokens upon successful authentication. - """ - logger.info(f"Login attempt for user: {form_data.username}") - user = await crud_user.get_user_by_email(db, email=form_data.username) - - if not user or not verify_password(form_data.password, user.password_hash): - logger.warning(f"Login failed: Invalid credentials for user {form_data.username}") - raise InvalidCredentialsError() - - access_token = create_access_token(subject=user.email) - refresh_token = create_refresh_token(subject=user.email) - logger.info(f"Login successful, tokens generated for user: {user.email}") - return Token( - access_token=access_token, - refresh_token=refresh_token, - token_type=settings.TOKEN_TYPE - ) - -@router.post( - "/refresh", - response_model=Token, - summary="Refresh Access Token", - description="Refreshes an access token using a refresh token.", - tags=["Authentication"] -) -async def refresh_token( - refresh_token_str: str, - db: AsyncSession = Depends(get_db) -): - """ - Handles access token refresh. - - Verifies the provided refresh token. - - If valid, generates and returns a new JWT access token and a new refresh token. - """ - logger.info("Access token refresh attempt") - payload = verify_refresh_token(refresh_token_str) - if not payload: - logger.warning("Refresh token invalid or expired") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired refresh token", - headers={"WWW-Authenticate": "Bearer"}, - ) - - user_email = payload.get("sub") - if not user_email: - logger.error("User email not found in refresh token payload") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid refresh token payload", - headers={"WWW-Authenticate": "Bearer"}, - ) - - new_access_token = create_access_token(subject=user_email) - new_refresh_token = create_refresh_token(subject=user_email) - logger.info(f"Access token refreshed and new refresh token issued for user: {user_email}") - return Token( - access_token=new_access_token, - refresh_token=new_refresh_token, - token_type=settings.TOKEN_TYPE - ) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/costs.py b/be/app/api/v1/endpoints/costs.py index 1288dc8..fbb3f1a 100644 --- a/be/app/api/v1/endpoints/costs.py +++ b/be/app/api/v1/endpoints/costs.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, selectinload from decimal import Decimal, ROUND_HALF_UP from app.database import get_db -from app.api.dependencies import get_current_user +from app.auth import current_active_user from app.models import ( User as UserModel, Group as GroupModel, @@ -41,7 +41,7 @@ router = APIRouter() async def get_list_cost_summary( list_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Retrieves a calculated cost summary for a specific list, detailing total costs, @@ -184,7 +184,7 @@ async def get_list_cost_summary( async def get_group_balance_summary( group_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Retrieves a detailed financial balance summary for all users within a specific group. diff --git a/be/app/api/v1/endpoints/financials.py b/be/app/api/v1/endpoints/financials.py index 558576b..ca38289 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -6,7 +6,7 @@ from sqlalchemy import select from typing import List as PyList, Optional, Sequence from app.database import get_db -from app.api.dependencies import get_current_user +from app.auth import current_active_user from app.models import User as UserModel, Group as GroupModel, List as ListModel, UserGroup as UserGroupModel, UserRoleEnum from app.schemas.expense import ( ExpenseCreate, ExpensePublic, @@ -47,7 +47,7 @@ async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_ async def create_new_expense( expense_in: ExpenseCreate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} creating expense: {expense_in.description}") effective_group_id = expense_in.group_id @@ -110,7 +110,7 @@ async def create_new_expense( async def get_expense( expense_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} requesting expense ID {expense_id}") expense = await crud_expense.get_expense_by_id(db, expense_id=expense_id) @@ -131,7 +131,7 @@ async def list_list_expenses( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} listing expenses for list ID {list_id}") await check_list_access_for_financials(db, list_id, current_user.id) @@ -144,7 +144,7 @@ async def list_group_expenses( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} listing expenses for group ID {group_id}") await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list expenses for") @@ -156,7 +156,7 @@ async def update_expense_details( expense_id: int, expense_in: ExpenseUpdate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Updates an existing expense (description, currency, expense_date only). @@ -210,7 +210,7 @@ async def delete_expense_record( expense_id: int, expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Deletes an expense and its associated splits. @@ -274,7 +274,7 @@ async def delete_expense_record( async def create_new_settlement( settlement_in: SettlementCreate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} recording settlement in group {settlement_in.group_id}") await crud_group.check_group_membership(db, group_id=settlement_in.group_id, user_id=current_user.id, action="record settlements in") @@ -300,7 +300,7 @@ async def create_new_settlement( async def get_settlement( settlement_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} requesting settlement ID {settlement_id}") settlement = await crud_settlement.get_settlement_by_id(db, settlement_id=settlement_id) @@ -322,7 +322,7 @@ async def list_group_settlements( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): logger.info(f"User {current_user.email} listing settlements for group ID {group_id}") await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list settlements for this group") @@ -334,7 +334,7 @@ async def update_settlement_details( settlement_id: int, settlement_in: SettlementUpdate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Updates an existing settlement (description, settlement_date only). @@ -388,7 +388,7 @@ async def delete_settlement_record( settlement_id: int, expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Deletes a settlement. diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py index 1b5ead7..0145696 100644 --- a/be/app/api/v1/endpoints/groups.py +++ b/be/app/api/v1/endpoints/groups.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.api.dependencies import get_current_user +from app.auth import current_active_user from app.models import User as UserModel, UserRoleEnum # Import model and enum from app.schemas.group import GroupCreate, GroupPublic from app.schemas.invite import InviteCodePublic @@ -37,7 +37,7 @@ router = APIRouter() async def create_group( group_in: GroupCreate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Creates a new group, adding the creator as the owner.""" logger.info(f"User {current_user.email} creating group: {group_in.name}") @@ -55,7 +55,7 @@ async def create_group( ) async def read_user_groups( db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Retrieves all groups the current user is a member of.""" logger.info(f"Fetching groups for user: {current_user.email}") @@ -72,7 +72,7 @@ async def read_user_groups( async def read_group( group_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Retrieves details for a specific group, including members, if the user is part of it.""" logger.info(f"User {current_user.email} requesting details for group ID: {group_id}") @@ -99,7 +99,7 @@ async def read_group( async def create_group_invite( group_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Generates a new invite code for the group. Requires owner/admin role (MVP: owner only).""" logger.info(f"User {current_user.email} attempting to create invite for group {group_id}") @@ -132,7 +132,7 @@ async def create_group_invite( async def leave_group( group_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Removes the current user from the specified group.""" logger.info(f"User {current_user.email} attempting to leave group {group_id}") @@ -171,7 +171,7 @@ async def remove_group_member( group_id: int, user_id_to_remove: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Removes a specified user from the group. Requires current user to be owner.""" logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}") @@ -210,7 +210,7 @@ async def remove_group_member( async def read_group_lists( group_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Retrieves all lists belonging to a specific group, if the user is a member.""" logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}") diff --git a/be/app/api/v1/endpoints/invites.py b/be/app/api/v1/endpoints/invites.py index b60ed0b..1a0006d 100644 --- a/be/app/api/v1/endpoints/invites.py +++ b/be/app/api/v1/endpoints/invites.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.api.dependencies import get_current_user +from app.auth import current_active_user from app.models import User as UserModel, UserRoleEnum from app.schemas.invite import InviteAccept from app.schemas.message import Message @@ -31,7 +31,7 @@ router = APIRouter() async def accept_invite( invite_in: InviteAccept, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Accepts a group invite using the provided invite code.""" logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}") diff --git a/be/app/api/v1/endpoints/items.py b/be/app/api/v1/endpoints/items.py index 38d130f..bae1019 100644 --- a/be/app/api/v1/endpoints/items.py +++ b/be/app/api/v1/endpoints/items.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.api.dependencies import get_current_user +from app.auth import current_active_user # --- Import Models Correctly --- from app.models import User as UserModel from app.models import Item as ItemModel # <-- IMPORT Item and alias it @@ -24,7 +24,7 @@ router = APIRouter() async def get_item_and_verify_access( item_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user) + current_user: UserModel = Depends(current_active_user) ) -> ItemModel: """Dependency to get an item and verify the user has access to its list.""" item_db = await crud_item.get_item_by_id(db, item_id=item_id) @@ -53,7 +53,7 @@ async def create_list_item( list_id: int, item_in: ItemCreate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """Adds a new item to a specific list. User must have access to the list.""" user_email = current_user.email # Access email attribute before async operations @@ -81,7 +81,7 @@ async def create_list_item( async def read_list_items( list_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), # Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc' ): """Retrieves all items for a specific list if the user has access.""" @@ -112,7 +112,7 @@ async def update_item( item_in: ItemUpdate, item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by + current_user: UserModel = Depends(current_active_user), # Need user ID for completed_by ): """ Updates an item's details (name, quantity, is_complete, price). @@ -153,7 +153,7 @@ async def delete_item( expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."), item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), # Log who deleted it + current_user: UserModel = Depends(current_active_user), # Log who deleted it ): """ Deletes an item. User must have access to the list the item belongs to. diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index 5683b07..a91b6e9 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query # from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.api.dependencies import get_current_user +from app.auth import current_active_user from app.models import User as UserModel from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail from app.schemas.message import Message # For simple responses @@ -34,7 +34,7 @@ router = APIRouter() async def create_list( list_in: ListCreate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Creates a new shopping list. @@ -64,7 +64,7 @@ async def create_list( ) async def read_lists( db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), # Add pagination parameters later if needed: skip: int = 0, limit: int = 100 ): """ @@ -86,7 +86,7 @@ async def read_lists( async def read_list( list_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Retrieves details for a specific list, including its items, @@ -111,7 +111,7 @@ async def update_list( list_id: int, list_in: ListUpdate, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Updates a list's details (name, description, is_complete). @@ -149,7 +149,7 @@ async def delete_list( list_id: int, expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Deletes a list. Requires user to be the creator of the list. @@ -184,7 +184,7 @@ async def delete_list( async def read_list_status( list_id: int, db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), ): """ Retrieves the last update time for the list and its items, plus item count. diff --git a/be/app/api/v1/endpoints/ocr.py b/be/app/api/v1/endpoints/ocr.py index 4600152..14192a4 100644 --- a/be/app/api/v1/endpoints/ocr.py +++ b/be/app/api/v1/endpoints/ocr.py @@ -4,7 +4,7 @@ from typing import List from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status from google.api_core import exceptions as google_exceptions -from app.api.dependencies import get_current_user +from app.auth import current_active_user from app.models import User as UserModel from app.schemas.ocr import OcrExtractResponse from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService @@ -30,7 +30,7 @@ ocr_service = GeminiOCRService() tags=["OCR"] ) async def ocr_extract_items( - current_user: UserModel = Depends(get_current_user), + current_user: UserModel = Depends(current_active_user), image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."), ): """ diff --git a/be/app/api/v1/endpoints/users.py b/be/app/api/v1/endpoints/users.py deleted file mode 100644 index b0e9946..0000000 --- a/be/app/api/v1/endpoints/users.py +++ /dev/null @@ -1,30 +0,0 @@ -# app/api/v1/endpoints/users.py -import logging -from fastapi import APIRouter, Depends, HTTPException - -from app.api.dependencies import get_current_user # Import the dependency -from app.schemas.user import UserPublic # Import the response schema -from app.models import User as UserModel # Import the DB model for type hinting - -logger = logging.getLogger(__name__) -router = APIRouter() - -@router.get( - "/me", - response_model=UserPublic, # Use the public schema to avoid exposing hash - summary="Get Current User", - description="Retrieves the details of the currently authenticated user.", - tags=["Users"] -) -async def read_users_me( - current_user: UserModel = Depends(get_current_user) # Apply the dependency -): - """ - Returns the data for the user associated with the current valid access token. - """ - logger.info(f"Fetching details for current user: {current_user.email}") - # The 'current_user' object is the SQLAlchemy model instance returned by the dependency. - # Pydantic's response_model will automatically convert it using UserPublic schema. - return current_user - -# Add other user-related endpoints here later (e.g., update user, list users (admin)) \ No newline at end of file diff --git a/be/app/auth.py b/be/app/auth.py index 6569762..e471309 100644 --- a/be/app/auth.py +++ b/be/app/auth.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from authlib.integrations.starlette_client import OAuth from starlette.config import Config from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import Response from .database import get_async_session from .models import User @@ -60,7 +61,7 @@ class UserManager(IntegerIDMixin, BaseUserManager[User, int]): print(f"Verification requested for user {user.id}. Verification token: {token}") async def on_after_login( - self, user: User, request: Optional[Request] = None + self, user: User, request: Optional[Request] = None, response: Optional[Response] = None ): print(f"User {user.id} has logged in.") @@ -73,7 +74,7 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") def get_jwt_strategy() -> JWTStrategy: - return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=3600) + return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60) auth_backend = AuthenticationBackend( name="jwt", diff --git a/be/app/config.py b/be/app/config.py index 162f4aa..b92d91e 100644 --- a/be/app/config.py +++ b/be/app/config.py @@ -17,8 +17,7 @@ class Settings(BaseSettings): # --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users) SECRET_KEY: str # Must be set via environment variable # ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy - # ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Handled by FastAPI-Users strategy - # REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Handled by FastAPI-Users strategy + # ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # This specific line is commented, the one under Session Settings is used. # --- OCR Settings --- MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing @@ -60,10 +59,12 @@ Organic Bananas API_DOCS_URL: str = "/api/docs" API_REDOC_URL: str = "/api/redoc" CORS_ORIGINS: list[str] = [ - "http://localhost:5173", - "http://localhost:8000", - # Add your deployed frontend URL here later - # "https://your-frontend-domain.com", + "http://localhost:5173", # Frontend dev server + "http://localhost:5174", # Alternative Vite port + "http://localhost:8000", # Backend server + "http://127.0.0.1:5173", # Frontend with IP + "http://127.0.0.1:5174", # Alternative Vite with IP + "http://127.0.0.1:8000", # Backend with IP ] FRONTEND_URL: str = "http://localhost:5173" # URL for the frontend application @@ -81,15 +82,6 @@ Organic Bananas HEALTH_STATUS_OK: str = "ok" HEALTH_STATUS_ERROR: str = "error" - # --- Auth Settings --- (These are largely handled by FastAPI-Users now) - OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # FastAPI-Users has its own token URL - TOKEN_TYPE: str = "bearer" - # AUTH_HEADER_PREFIX: str = "Bearer" - # AUTH_HEADER_NAME: str = "WWW-Authenticate" - # AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials" - # AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password" - # AUTH_NOT_AUTHENTICATED: str = "Not authenticated" - # --- HTTP Status Messages --- HTTP_400_DETAIL: str = "Bad Request" HTTP_401_DETAIL: str = "Unauthorized" @@ -119,7 +111,7 @@ Organic Bananas # Session Settings SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production - + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 class Config: env_file = ".env" env_file_encoding = 'utf-8' diff --git a/be/app/core/security.py b/be/app/core/security.py index f269a9a..87ee4a1 100644 --- a/be/app/core/security.py +++ b/be/app/core/security.py @@ -45,120 +45,14 @@ def hash_password(password: str) -> str: # --- JSON Web Tokens (JWT) --- - -def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: - """ - Creates a JWT access token. - - Args: - subject: The subject of the token (e.g., user ID or email). - expires_delta: Optional timedelta object for token expiry. If None, - uses ACCESS_TOKEN_EXPIRE_MINUTES from settings. - - Returns: - The encoded JWT access token string. - """ - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) - - # Data to encode in the token payload - to_encode = {"exp": expire, "sub": str(subject), "type": "access"} - - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM - ) - return encoded_jwt - -def create_refresh_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: - """ - Creates a JWT refresh token. - - Args: - subject: The subject of the token (e.g., user ID or email). - expires_delta: Optional timedelta object for token expiry. If None, - uses REFRESH_TOKEN_EXPIRE_MINUTES from settings. - - Returns: - The encoded JWT refresh token string. - """ - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta( - minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES - ) - - # Data to encode in the token payload - to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"} - - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM - ) - return encoded_jwt - -def verify_access_token(token: str) -> Optional[dict]: - """ - Verifies a JWT access token and returns its payload if valid. - - Args: - token: The JWT token string to verify. - - Returns: - The decoded token payload (dict) if the token is valid and not expired, - otherwise None. - """ - try: - # Decode the token. This also automatically verifies: - # - Signature (using SECRET_KEY and ALGORITHM) - # - Expiration ('exp' claim) - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] - ) - if payload.get("type") != "access": - raise JWTError("Invalid token type") - return payload - except JWTError as e: - # Handles InvalidSignatureError, ExpiredSignatureError, etc. - print(f"JWT Error: {e}") # Log the error for debugging - return None - except Exception as e: - # Handle other potential unexpected errors during decoding - print(f"Unexpected error decoding JWT: {e}") - return None - -def verify_refresh_token(token: str) -> Optional[dict]: - """ - Verifies a JWT refresh token and returns its payload if valid. - - Args: - token: The JWT token string to verify. - - Returns: - The decoded token payload (dict) if the token is valid, not expired, - and is a refresh token, otherwise None. - """ - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] - ) - if payload.get("type") != "refresh": - raise JWTError("Invalid token type") - return payload - except JWTError as e: - print(f"JWT Error: {e}") # Log the error for debugging - return None - except Exception as e: - print(f"Unexpected error decoding JWT: {e}") - return None +# FastAPI-Users now handles all tokenization. # You might add a function here later to extract the 'sub' (subject/user id) # specifically, often used in dependency injection for authentication. # def get_subject_from_token(token: str) -> Optional[str]: -# payload = verify_access_token(token) +# # This would need to use FastAPI-Users' token verification if ever implemented +# # For example, by decoding the token using the strategy from the auth backend +# payload = {} # Placeholder for actual token decoding logic # if payload: # return payload.get("sub") # return None \ No newline at end of file diff --git a/be/app/main.py b/be/app/main.py index f902b89..4403209 100644 --- a/be/app/main.py +++ b/be/app/main.py @@ -49,23 +49,13 @@ app.add_middleware( ) # --- CORS Middleware --- -# Define allowed origins. Be specific in production! -# Use ["*"] for wide open access during early development if needed, -# but restrict it as soon as possible. -# SvelteKit default dev port is 5173 -origins = [ - "http://localhost:5174", - "http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI) - # Add your deployed frontend URL here later - # "https://your-frontend-domain.com", -] - app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["*"] ) # --- End CORS Middleware --- @@ -114,7 +104,7 @@ async def read_root(): Useful for basic reachability checks. """ logger.info("Root endpoint '/' accessed.") - return {"message": settings.ROOT_MESSAGE} + return {"message": "Welcome to the API"} # --- End Root Endpoint --- diff --git a/be/app/schemas/user.py b/be/app/schemas/user.py index b26b727..ed5332c 100644 --- a/be/app/schemas/user.py +++ b/be/app/schemas/user.py @@ -12,6 +12,16 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str + def create_update_dict(self): + return { + "email": self.email, + "name": self.name, + "password": self.password, + "is_active": True, + "is_superuser": False, + "is_verified": False + } + # Properties to receive via API on update class UserUpdate(UserBase): password: Optional[str] = None diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts index abe3347..5b9bb3e 100644 --- a/fe/src/config/api-config.ts +++ b/fe/src/config/api-config.ts @@ -8,24 +8,23 @@ export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:80 export const API_ENDPOINTS = { // Auth AUTH: { - LOGIN: '/auth/login', - SIGNUP: '/auth/signup', - REFRESH_TOKEN: '/auth/refresh-token', - LOGOUT: '/auth/logout', - VERIFY_EMAIL: '/auth/verify-email', - RESET_PASSWORD: '/auth/reset-password', + LOGIN: '/auth/jwt/login', + SIGNUP: '/auth/register', + LOGOUT: '/auth/jwt/logout', + VERIFY_EMAIL: '/auth/verify', + RESET_PASSWORD: '/auth/forgot-password', FORGOT_PASSWORD: '/auth/forgot-password', }, // Users USERS: { PROFILE: '/users/me', - UPDATE_PROFILE: '/users/me', - PASSWORD: '/users/password', - AVATAR: '/users/avatar', - SETTINGS: '/users/settings', - NOTIFICATIONS: '/users/notifications', - PREFERENCES: '/users/preferences', + UPDATE_PROFILE: '/api/v1/users/me', + PASSWORD: '/api/v1/users/password', + AVATAR: '/api/v1/users/avatar', + SETTINGS: '/api/v1/users/settings', + NOTIFICATIONS: '/api/v1/users/notifications', + PREFERENCES: '/api/v1/users/preferences', }, // Lists diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 09cb13c..b7ec1cd 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -9,6 +9,7 @@ const api = axios.create({ headers: { 'Content-Type': 'application/json', }, + withCredentials: true, // Enable sending cookies and authentication headers }); // Request interceptor @@ -46,7 +47,7 @@ api.interceptors.response.use( // Use the store's refresh mechanism if it already handles API call and token setting // However, the interceptor is specifically for retrying requests, so direct call is fine here // as long as it correctly updates tokens for the subsequent retry. - const response = await api.post('/api/v1/auth/refresh', { + const response = await api.post('/auth/jwt/refresh', { refresh_token: refreshTokenValue, }); @@ -77,10 +78,8 @@ export { api, globalAxios }; import { API_VERSION, API_ENDPOINTS } from '@/config/api-config'; export const getApiUrl = (endpoint: string): string => { - // Assuming API_BASE_URL already includes http://localhost:8000 - // and endpoint starts with / - // The original `getApiUrl` added /api/v1, ensure this is correct for your setup - return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`; + // The API_ENDPOINTS already include the full path, so we just need to combine with base URL + return `${API_BASE_URL}${endpoint}`; }; export const apiClient = { diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 8c3c0f1..d88c916 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -7,7 +7,6 @@ import router from '@/router'; interface AuthState { accessToken: string | null; - refreshToken: string | null; user: { email: string; name: string; @@ -18,7 +17,6 @@ interface AuthState { export const useAuthStore = defineStore('auth', () => { // State const accessToken = ref(localStorage.getItem('token')); - const refreshToken = ref(localStorage.getItem('refresh_token')); const user = ref(null); // Getters @@ -26,19 +24,15 @@ export const useAuthStore = defineStore('auth', () => { const getUser = computed(() => user.value); // Actions - const setTokens = (tokens: { access_token: string; refresh_token: string }) => { + const setTokens = (tokens: { access_token: string }) => { accessToken.value = tokens.access_token; - refreshToken.value = tokens.refresh_token; localStorage.setItem('token', tokens.access_token); - localStorage.setItem('refresh_token', tokens.refresh_token); }; const clearTokens = () => { accessToken.value = null; - refreshToken.value = null; user.value = null; localStorage.removeItem('token'); - localStorage.removeItem('refresh_token'); }; const setUser = (userData: AuthState['user']) => { @@ -56,20 +50,8 @@ export const useAuthStore = defineStore('auth', () => { return response.data; } catch (error: any) { console.error('AuthStore: Failed to fetch current user:', error); - if (error.response?.status === 401) { - try { - await refreshAccessToken(); - const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE); - setUser(response.data); - return response.data; - } catch (refreshOrRetryError) { - console.error('AuthStore: Failed to refresh token or fetch user after refresh:', refreshOrRetryError); - return null; - } - } else { - clearTokens(); - return null; - } + clearTokens(); + return null; } }; @@ -84,8 +66,8 @@ export const useAuthStore = defineStore('auth', () => { }, }); - const { access_token, refresh_token } = response.data; - setTokens({ access_token, refresh_token }); + const { access_token } = response.data; + setTokens({ access_token }); await fetchCurrentUser(); return response.data; }; @@ -95,29 +77,6 @@ export const useAuthStore = defineStore('auth', () => { return response.data; }; - const refreshAccessToken = async () => { - if (!refreshToken.value) { - clearTokens(); - await router.push('/auth/login'); - throw new Error('No refresh token available'); - } - - try { - const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH_TOKEN, { - refresh_token: refreshToken.value, - }); - - const { access_token, refresh_token: newRefreshToken } = response.data; - setTokens({ access_token, refresh_token: newRefreshToken }); - return response.data; - } catch (error) { - console.error('AuthStore: Refresh token failed:', error); - clearTokens(); - await router.push('/auth/login'); - throw error; - } - }; - const logout = async () => { clearTokens(); await router.push('/auth/login'); @@ -125,7 +84,6 @@ export const useAuthStore = defineStore('auth', () => { return { accessToken, - refreshToken, user, isAuthenticated, getUser, @@ -135,7 +93,6 @@ export const useAuthStore = defineStore('auth', () => { fetchCurrentUser, login, signup, - refreshAccessToken, logout, }; });