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.

This commit is contained in:
mohamad 2025-05-14 01:04:09 +02:00
parent 72b988b79b
commit 3f0cfff9f1
19 changed files with 87 additions and 487 deletions

View File

@ -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

View File

@ -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"])

View File

@ -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
)

View File

@ -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.

View File

@ -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.

View File

@ -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}")

View File

@ -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}")

View File

@ -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.

View File

@ -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.

View File

@ -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."),
):
"""

View File

@ -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))

View File

@ -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",

View File

@ -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'

View File

@ -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

View File

@ -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 ---

View File

@ -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

View File

@ -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

View File

@ -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 = {

View File

@ -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<string | null>(localStorage.getItem('token'));
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
const user = ref<AuthState['user']>(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,
};
});