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:
parent
72b988b79b
commit
3f0cfff9f1
@ -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
|
|
@ -1,8 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1.endpoints import health
|
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 groups
|
||||||
from app.api.v1.endpoints import invites
|
from app.api.v1.endpoints import invites
|
||||||
from app.api.v1.endpoints import lists
|
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 = APIRouter()
|
||||||
|
|
||||||
api_router_v1.include_router(health.router)
|
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(groups.router, prefix="/groups", tags=["Groups"])
|
||||||
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
|
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
|
||||||
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
|
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
|
||||||
|
@ -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
|
|
||||||
)
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
from app.database import get_db
|
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 (
|
from app.models import (
|
||||||
User as UserModel,
|
User as UserModel,
|
||||||
Group as GroupModel,
|
Group as GroupModel,
|
||||||
@ -41,7 +41,7 @@ router = APIRouter()
|
|||||||
async def get_list_cost_summary(
|
async def get_list_cost_summary(
|
||||||
list_id: int,
|
list_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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,
|
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(
|
async def get_group_balance_summary(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Retrieves a detailed financial balance summary for all users within a specific group.
|
||||||
|
@ -6,7 +6,7 @@ from sqlalchemy import select
|
|||||||
from typing import List as PyList, Optional, Sequence
|
from typing import List as PyList, Optional, Sequence
|
||||||
|
|
||||||
from app.database import get_db
|
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.models import User as UserModel, Group as GroupModel, List as ListModel, UserGroup as UserGroupModel, UserRoleEnum
|
||||||
from app.schemas.expense import (
|
from app.schemas.expense import (
|
||||||
ExpenseCreate, ExpensePublic,
|
ExpenseCreate, ExpensePublic,
|
||||||
@ -47,7 +47,7 @@ async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_
|
|||||||
async def create_new_expense(
|
async def create_new_expense(
|
||||||
expense_in: ExpenseCreate,
|
expense_in: ExpenseCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
logger.info(f"User {current_user.email} creating expense: {expense_in.description}")
|
||||||
effective_group_id = expense_in.group_id
|
effective_group_id = expense_in.group_id
|
||||||
@ -110,7 +110,7 @@ async def create_new_expense(
|
|||||||
async def get_expense(
|
async def get_expense(
|
||||||
expense_id: int,
|
expense_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
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)
|
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),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=200),
|
limit: int = Query(100, ge=1, le=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
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)
|
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),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=200),
|
limit: int = Query(100, ge=1, le=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
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")
|
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_id: int,
|
||||||
expense_in: ExpenseUpdate,
|
expense_in: ExpenseUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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).
|
Updates an existing expense (description, currency, expense_date only).
|
||||||
@ -210,7 +210,7 @@ async def delete_expense_record(
|
|||||||
expense_id: int,
|
expense_id: int,
|
||||||
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
|
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
|
||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Deletes an expense and its associated splits.
|
||||||
@ -274,7 +274,7 @@ async def delete_expense_record(
|
|||||||
async def create_new_settlement(
|
async def create_new_settlement(
|
||||||
settlement_in: SettlementCreate,
|
settlement_in: SettlementCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
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")
|
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(
|
async def get_settlement(
|
||||||
settlement_id: int,
|
settlement_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
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)
|
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),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=200),
|
limit: int = Query(100, ge=1, le=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
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}")
|
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")
|
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_id: int,
|
||||||
settlement_in: SettlementUpdate,
|
settlement_in: SettlementUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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).
|
Updates an existing settlement (description, settlement_date only).
|
||||||
@ -388,7 +388,7 @@ async def delete_settlement_record(
|
|||||||
settlement_id: int,
|
settlement_id: int,
|
||||||
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
|
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: UserModel = Depends(get_current_user),
|
current_user: UserModel = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Deletes a settlement.
|
Deletes a settlement.
|
||||||
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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.models import User as UserModel, UserRoleEnum # Import model and enum
|
||||||
from app.schemas.group import GroupCreate, GroupPublic
|
from app.schemas.group import GroupCreate, GroupPublic
|
||||||
from app.schemas.invite import InviteCodePublic
|
from app.schemas.invite import InviteCodePublic
|
||||||
@ -37,7 +37,7 @@ router = APIRouter()
|
|||||||
async def create_group(
|
async def create_group(
|
||||||
group_in: GroupCreate,
|
group_in: GroupCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""Creates a new group, adding the creator as the owner."""
|
||||||
logger.info(f"User {current_user.email} creating group: {group_in.name}")
|
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(
|
async def read_user_groups(
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""Retrieves all groups the current user is a member of."""
|
||||||
logger.info(f"Fetching groups for user: {current_user.email}")
|
logger.info(f"Fetching groups for user: {current_user.email}")
|
||||||
@ -72,7 +72,7 @@ async def read_user_groups(
|
|||||||
async def read_group(
|
async def read_group(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""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}")
|
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(
|
async def create_group_invite(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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)."""
|
"""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}")
|
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(
|
async def leave_group(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""Removes the current user from the specified group."""
|
||||||
logger.info(f"User {current_user.email} attempting to leave group {group_id}")
|
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,
|
group_id: int,
|
||||||
user_id_to_remove: int,
|
user_id_to_remove: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""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}")
|
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(
|
async def read_group_lists(
|
||||||
group_id: int,
|
group_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""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}")
|
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
|
||||||
|
@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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.models import User as UserModel, UserRoleEnum
|
||||||
from app.schemas.invite import InviteAccept
|
from app.schemas.invite import InviteAccept
|
||||||
from app.schemas.message import Message
|
from app.schemas.message import Message
|
||||||
@ -31,7 +31,7 @@ router = APIRouter()
|
|||||||
async def accept_invite(
|
async def accept_invite(
|
||||||
invite_in: InviteAccept,
|
invite_in: InviteAccept,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""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}")
|
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}")
|
||||||
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.api.dependencies import get_current_user
|
from app.auth import current_active_user
|
||||||
# --- Import Models Correctly ---
|
# --- Import Models Correctly ---
|
||||||
from app.models import User as UserModel
|
from app.models import User as UserModel
|
||||||
from app.models import Item as ItemModel # <-- IMPORT Item and alias it
|
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(
|
async def get_item_and_verify_access(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: UserModel = Depends(get_current_user)
|
current_user: UserModel = Depends(current_active_user)
|
||||||
) -> ItemModel:
|
) -> ItemModel:
|
||||||
"""Dependency to get an item and verify the user has access to its list."""
|
"""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)
|
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,
|
list_id: int,
|
||||||
item_in: ItemCreate,
|
item_in: ItemCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""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
|
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(
|
async def read_list_items(
|
||||||
list_id: int,
|
list_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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'
|
# 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."""
|
"""Retrieves all items for a specific list if the user has access."""
|
||||||
@ -112,7 +112,7 @@ async def update_item(
|
|||||||
item_in: ItemUpdate,
|
item_in: ItemUpdate,
|
||||||
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
|
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
|
||||||
db: AsyncSession = Depends(get_db),
|
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).
|
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."),
|
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
|
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
|
||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Deletes an item. User must have access to the list the item belongs to.
|
||||||
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query #
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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.models import User as UserModel
|
||||||
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
|
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
|
||||||
from app.schemas.message import Message # For simple responses
|
from app.schemas.message import Message # For simple responses
|
||||||
@ -34,7 +34,7 @@ router = APIRouter()
|
|||||||
async def create_list(
|
async def create_list(
|
||||||
list_in: ListCreate,
|
list_in: ListCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: UserModel = Depends(get_current_user),
|
current_user: UserModel = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Creates a new shopping list.
|
Creates a new shopping list.
|
||||||
@ -64,7 +64,7 @@ async def create_list(
|
|||||||
)
|
)
|
||||||
async def read_lists(
|
async def read_lists(
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -86,7 +86,7 @@ async def read_lists(
|
|||||||
async def read_list(
|
async def read_list(
|
||||||
list_id: int,
|
list_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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,
|
Retrieves details for a specific list, including its items,
|
||||||
@ -111,7 +111,7 @@ async def update_list(
|
|||||||
list_id: int,
|
list_id: int,
|
||||||
list_in: ListUpdate,
|
list_in: ListUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
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).
|
Updates a list's details (name, description, is_complete).
|
||||||
@ -149,7 +149,7 @@ async def delete_list(
|
|||||||
list_id: int,
|
list_id: int,
|
||||||
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
|
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
|
||||||
db: AsyncSession = Depends(get_db),
|
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.
|
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(
|
async def read_list_status(
|
||||||
list_id: int,
|
list_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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.
|
Retrieves the last update time for the list and its items, plus item count.
|
||||||
|
@ -4,7 +4,7 @@ from typing import List
|
|||||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
|
||||||
from google.api_core import exceptions as google_exceptions
|
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.models import User as UserModel
|
||||||
from app.schemas.ocr import OcrExtractResponse
|
from app.schemas.ocr import OcrExtractResponse
|
||||||
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService
|
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService
|
||||||
@ -30,7 +30,7 @@ ocr_service = GeminiOCRService()
|
|||||||
tags=["OCR"]
|
tags=["OCR"]
|
||||||
)
|
)
|
||||||
async def ocr_extract_items(
|
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."),
|
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
@ -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))
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from authlib.integrations.starlette_client import OAuth
|
from authlib.integrations.starlette_client import OAuth
|
||||||
from starlette.config import Config
|
from starlette.config import Config
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from .database import get_async_session
|
from .database import get_async_session
|
||||||
from .models import User
|
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}")
|
print(f"Verification requested for user {user.id}. Verification token: {token}")
|
||||||
|
|
||||||
async def on_after_login(
|
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.")
|
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")
|
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
|
||||||
|
|
||||||
def get_jwt_strategy() -> JWTStrategy:
|
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(
|
auth_backend = AuthenticationBackend(
|
||||||
name="jwt",
|
name="jwt",
|
||||||
|
@ -17,8 +17,7 @@ class Settings(BaseSettings):
|
|||||||
# --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users)
|
# --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users)
|
||||||
SECRET_KEY: str # Must be set via environment variable
|
SECRET_KEY: str # Must be set via environment variable
|
||||||
# ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy
|
# ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy
|
||||||
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Handled by FastAPI-Users strategy
|
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # This specific line is commented, the one under Session Settings is used.
|
||||||
# REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Handled by FastAPI-Users strategy
|
|
||||||
|
|
||||||
# --- OCR Settings ---
|
# --- OCR Settings ---
|
||||||
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
|
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_DOCS_URL: str = "/api/docs"
|
||||||
API_REDOC_URL: str = "/api/redoc"
|
API_REDOC_URL: str = "/api/redoc"
|
||||||
CORS_ORIGINS: list[str] = [
|
CORS_ORIGINS: list[str] = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173", # Frontend dev server
|
||||||
"http://localhost:8000",
|
"http://localhost:5174", # Alternative Vite port
|
||||||
# Add your deployed frontend URL here later
|
"http://localhost:8000", # Backend server
|
||||||
# "https://your-frontend-domain.com",
|
"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
|
FRONTEND_URL: str = "http://localhost:5173" # URL for the frontend application
|
||||||
|
|
||||||
@ -81,15 +82,6 @@ Organic Bananas
|
|||||||
HEALTH_STATUS_OK: str = "ok"
|
HEALTH_STATUS_OK: str = "ok"
|
||||||
HEALTH_STATUS_ERROR: str = "error"
|
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 Status Messages ---
|
||||||
HTTP_400_DETAIL: str = "Bad Request"
|
HTTP_400_DETAIL: str = "Bad Request"
|
||||||
HTTP_401_DETAIL: str = "Unauthorized"
|
HTTP_401_DETAIL: str = "Unauthorized"
|
||||||
@ -119,7 +111,7 @@ Organic Bananas
|
|||||||
|
|
||||||
# Session Settings
|
# Session Settings
|
||||||
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production
|
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = 'utf-8'
|
env_file_encoding = 'utf-8'
|
||||||
|
@ -45,120 +45,14 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# --- JSON Web Tokens (JWT) ---
|
# --- JSON Web Tokens (JWT) ---
|
||||||
|
# FastAPI-Users now handles all tokenization.
|
||||||
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
|
|
||||||
|
|
||||||
# You might add a function here later to extract the 'sub' (subject/user id)
|
# You might add a function here later to extract the 'sub' (subject/user id)
|
||||||
# specifically, often used in dependency injection for authentication.
|
# specifically, often used in dependency injection for authentication.
|
||||||
# def get_subject_from_token(token: str) -> Optional[str]:
|
# 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:
|
# if payload:
|
||||||
# return payload.get("sub")
|
# return payload.get("sub")
|
||||||
# return None
|
# return None
|
@ -49,23 +49,13 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- CORS 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=["*"]
|
||||||
)
|
)
|
||||||
# --- End CORS Middleware ---
|
# --- End CORS Middleware ---
|
||||||
|
|
||||||
@ -114,7 +104,7 @@ async def read_root():
|
|||||||
Useful for basic reachability checks.
|
Useful for basic reachability checks.
|
||||||
"""
|
"""
|
||||||
logger.info("Root endpoint '/' accessed.")
|
logger.info("Root endpoint '/' accessed.")
|
||||||
return {"message": settings.ROOT_MESSAGE}
|
return {"message": "Welcome to the API"}
|
||||||
# --- End Root Endpoint ---
|
# --- End Root Endpoint ---
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,6 +12,16 @@ class UserBase(BaseModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str
|
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
|
# Properties to receive via API on update
|
||||||
class UserUpdate(UserBase):
|
class UserUpdate(UserBase):
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
|
@ -8,24 +8,23 @@ export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:80
|
|||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
// Auth
|
// Auth
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: '/auth/login',
|
LOGIN: '/auth/jwt/login',
|
||||||
SIGNUP: '/auth/signup',
|
SIGNUP: '/auth/register',
|
||||||
REFRESH_TOKEN: '/auth/refresh-token',
|
LOGOUT: '/auth/jwt/logout',
|
||||||
LOGOUT: '/auth/logout',
|
VERIFY_EMAIL: '/auth/verify',
|
||||||
VERIFY_EMAIL: '/auth/verify-email',
|
RESET_PASSWORD: '/auth/forgot-password',
|
||||||
RESET_PASSWORD: '/auth/reset-password',
|
|
||||||
FORGOT_PASSWORD: '/auth/forgot-password',
|
FORGOT_PASSWORD: '/auth/forgot-password',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
USERS: {
|
USERS: {
|
||||||
PROFILE: '/users/me',
|
PROFILE: '/users/me',
|
||||||
UPDATE_PROFILE: '/users/me',
|
UPDATE_PROFILE: '/api/v1/users/me',
|
||||||
PASSWORD: '/users/password',
|
PASSWORD: '/api/v1/users/password',
|
||||||
AVATAR: '/users/avatar',
|
AVATAR: '/api/v1/users/avatar',
|
||||||
SETTINGS: '/users/settings',
|
SETTINGS: '/api/v1/users/settings',
|
||||||
NOTIFICATIONS: '/users/notifications',
|
NOTIFICATIONS: '/api/v1/users/notifications',
|
||||||
PREFERENCES: '/users/preferences',
|
PREFERENCES: '/api/v1/users/preferences',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
|
@ -9,6 +9,7 @@ const api = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
withCredentials: true, // Enable sending cookies and authentication headers
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor
|
// 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
|
// 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
|
// 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.
|
// 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,
|
refresh_token: refreshTokenValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,10 +78,8 @@ export { api, globalAxios };
|
|||||||
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
|
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
|
||||||
|
|
||||||
export const getApiUrl = (endpoint: string): string => {
|
export const getApiUrl = (endpoint: string): string => {
|
||||||
// Assuming API_BASE_URL already includes http://localhost:8000
|
// The API_ENDPOINTS already include the full path, so we just need to combine with base URL
|
||||||
// and endpoint starts with /
|
return `${API_BASE_URL}${endpoint}`;
|
||||||
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
|
|
||||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
|
@ -7,7 +7,6 @@ import router from '@/router';
|
|||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
refreshToken: string | null;
|
|
||||||
user: {
|
user: {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -18,7 +17,6 @@ interface AuthState {
|
|||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// State
|
// State
|
||||||
const accessToken = ref<string | null>(localStorage.getItem('token'));
|
const accessToken = ref<string | null>(localStorage.getItem('token'));
|
||||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
|
|
||||||
const user = ref<AuthState['user']>(null);
|
const user = ref<AuthState['user']>(null);
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@ -26,19 +24,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const getUser = computed(() => user.value);
|
const getUser = computed(() => user.value);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const setTokens = (tokens: { access_token: string; refresh_token: string }) => {
|
const setTokens = (tokens: { access_token: string }) => {
|
||||||
accessToken.value = tokens.access_token;
|
accessToken.value = tokens.access_token;
|
||||||
refreshToken.value = tokens.refresh_token;
|
|
||||||
localStorage.setItem('token', tokens.access_token);
|
localStorage.setItem('token', tokens.access_token);
|
||||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearTokens = () => {
|
const clearTokens = () => {
|
||||||
accessToken.value = null;
|
accessToken.value = null;
|
||||||
refreshToken.value = null;
|
|
||||||
user.value = null;
|
user.value = null;
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setUser = (userData: AuthState['user']) => {
|
const setUser = (userData: AuthState['user']) => {
|
||||||
@ -56,20 +50,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('AuthStore: Failed to fetch current user:', error);
|
console.error('AuthStore: Failed to fetch current user:', error);
|
||||||
if (error.response?.status === 401) {
|
clearTokens();
|
||||||
try {
|
return null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -84,8 +66,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { access_token, refresh_token } = response.data;
|
const { access_token } = response.data;
|
||||||
setTokens({ access_token, refresh_token });
|
setTokens({ access_token });
|
||||||
await fetchCurrentUser();
|
await fetchCurrentUser();
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@ -95,29 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return response.data;
|
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 () => {
|
const logout = async () => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
await router.push('/auth/login');
|
await router.push('/auth/login');
|
||||||
@ -125,7 +84,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
|
||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
getUser,
|
getUser,
|
||||||
@ -135,7 +93,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
refreshAccessToken,
|
|
||||||
logout,
|
logout,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user