diff --git a/app/db/alembic/env.py b/app/db/alembic/env.py index 59e48c6..fe81294 100644 --- a/app/db/alembic/env.py +++ b/app/db/alembic/env.py @@ -18,8 +18,8 @@ def import_all_models(): # Correct the path to the models directory models_dir = os.path.join(os.path.dirname(__file__), "../models") - models_dir = os.path.abspath(models_dir) # Ensure it's an absolute path - + models_dir = os.path.abspath(models_dir) + for filename in os.listdir(models_dir): if filename.endswith(".py") and filename != "__init__.py": module_name = f"app.db.models.{filename[:-3]}" diff --git a/app/db/alembic/versions/5cfc72e753ba_transforming_schema.py b/app/db/alembic/versions/5cfc72e753ba_transforming_schema.py new file mode 100644 index 0000000..e4b6cdb --- /dev/null +++ b/app/db/alembic/versions/5cfc72e753ba_transforming_schema.py @@ -0,0 +1,54 @@ +"""transforming schema + +Revision ID: 5cfc72e753ba +Revises: 075b701a1a93 +Create Date: 2025-05-27 16:16:34.166953 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5cfc72e753ba' +down_revision: Union[str, None] = '075b701a1a93' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('permissions', + sa.Column('name', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('roles', + sa.Column('name', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + op.create_table('rules', + sa.Column('role_name', sa.String(length=50), nullable=False), + sa.Column('permission_name', sa.String(length=100), nullable=False), + sa.ForeignKeyConstraint(['permission_name'], ['permissions.name'], ), + sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ), + sa.PrimaryKeyConstraint('role_name', 'permission_name') + ) + op.add_column('users', sa.Column('role_name', sa.String(length=50), nullable=False)) + op.create_foreign_key(None, 'users', 'roles', ['role_name'], ['name']) + op.drop_column('users', 'role') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'users', type_='foreignkey') + op.drop_column('users', 'role_name') + op.drop_table('rules') + op.drop_table('roles') + op.drop_table('permissions') + # ### end Alembic commands ### diff --git a/app/db/alembic/versions/9951448af167_testing_schema_change_potentially.py b/app/db/alembic/versions/9951448af167_testing_schema_change_potentially.py new file mode 100644 index 0000000..17e2ad4 --- /dev/null +++ b/app/db/alembic/versions/9951448af167_testing_schema_change_potentially.py @@ -0,0 +1,32 @@ +"""testing schema change potentially + +Revision ID: 9951448af167 +Revises: 5cfc72e753ba +Create Date: 2025-05-27 19:07:18.419756 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9951448af167' +down_revision: Union[str, None] = '5cfc72e753ba' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/app/db/crud/auth.py b/app/db/crud/auth.py new file mode 100644 index 0000000..7eb5784 --- /dev/null +++ b/app/db/crud/auth.py @@ -0,0 +1,25 @@ +from sqlalchemy.orm import Session +from app.db.models import User, Rule + +def get_user_by_username(db: Session, username: str) -> User | None: + print("Debug: Listing all users in the database:") + all_users = db.query(User).all() + for user in all_users: + print(f" - id={user.id}, username={user.username}, email={user.email}, role={user.role_name}") + + print(f"Debug: Searching for user with username='{username}'") + user = db.query(User).filter(User.username == username).first() + if user: + print(f"Debug: Found user with id={user.id}") + else: + print("Debug: No user found") + return user + + +def get_permissions_for_role(db: Session, role_name: str) -> list[str]: + permissions = ( + db.query(Rule.permission_name) + .filter(Rule.role_name == role_name) + .all() + ) + return [perm.permission_name for perm in permissions] diff --git a/app/db/crud/orders.py b/app/db/crud/orders.py index addd54d..110d953 100644 --- a/app/db/crud/orders.py +++ b/app/db/crud/orders.py @@ -1,5 +1,5 @@ from sqlalchemy.orm import Session -from app.db.models.orders import Order +from app.db.models import Order def get_order_by_id(db: Session, order_id: int) -> Order | None: return db.query(Order).filter(Order.id == order_id).first() diff --git a/app/db/crud/users.py b/app/db/crud/users.py index f3b4626..ec20710 100644 --- a/app/db/crud/users.py +++ b/app/db/crud/users.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from fastapi import HTTPException, status -from app.db.models.users import User +from app.db.models import User from app.validators.users import UserUpdate def get_user_by_id(db: Session, user_id: int) -> User | None: diff --git a/app/db/models/__init__.py b/app/db/models/__init__.py new file mode 100644 index 0000000..c0fcd0d --- /dev/null +++ b/app/db/models/__init__.py @@ -0,0 +1,9 @@ +# Import your declarative base from app/db/base.py +from app.db.base import Base + +# Import all your model classes from their respective files within this directory +from .orders import Order +from .permissions import Permission +from .roles import Role +from .rules import Rule +from .users import User diff --git a/app/db/models/permissions.py b/app/db/models/permissions.py new file mode 100644 index 0000000..e9d8c3e --- /dev/null +++ b/app/db/models/permissions.py @@ -0,0 +1,9 @@ +from sqlalchemy import Column, String +from sqlalchemy.orm import relationship +from app.db.base import Base + +class Permission(Base): + __tablename__ = "permissions" + + name = Column(String(100), primary_key=True) + rules = relationship("Rule", back_populates="permission", cascade="all, delete-orphan") diff --git a/app/db/models/roles.py b/app/db/models/roles.py new file mode 100644 index 0000000..10a0602 --- /dev/null +++ b/app/db/models/roles.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, String +from sqlalchemy.orm import relationship +from app.db.base import Base + +class Role(Base): + __tablename__ = "roles" + + name = Column(String(50), primary_key=True) + users = relationship("User", back_populates="role") + rules = relationship("Rule", back_populates="role", cascade="all, delete-orphan") diff --git a/app/db/models/rules.py b/app/db/models/rules.py new file mode 100644 index 0000000..04d8840 --- /dev/null +++ b/app/db/models/rules.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, String, ForeignKey +from sqlalchemy.orm import relationship +from app.db.base import Base + +class Rule(Base): + __tablename__ = "rules" + + role_name = Column(String(50), ForeignKey("roles.name"), primary_key=True) + permission_name = Column(String(100), ForeignKey("permissions.name"), primary_key=True) + + role = relationship("Role", back_populates="rules") + permission = relationship("Permission", back_populates="rules") diff --git a/app/db/models/users.py b/app/db/models/users.py index 4f29f7b..c0508d8 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -1,7 +1,6 @@ -from sqlalchemy import Column, Integer, String, DateTime, func +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func from sqlalchemy.orm import relationship from app.db.base import Base -from app.db.models.orders import Order class User(Base): __tablename__ = "users" @@ -10,8 +9,9 @@ class User(Base): username = Column(String(255), unique=True, nullable=False) email = Column(String(255), unique=True, nullable=False) hashed_password = Column(String(255), nullable=False) - role = Column(String(50), nullable=False, default="customer") + role_name = Column(String(50), ForeignKey("roles.name"), nullable=False, default="customer") created_at = Column(DateTime, server_default=func.now(), nullable=False) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) - orders = relationship(Order, back_populates="user", cascade="all, delete-orphan") + role = relationship("Role", back_populates="users") + orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") # Use string reference \ No newline at end of file diff --git a/app/db/seed.py b/app/db/seed.py new file mode 100644 index 0000000..4aaa1cb --- /dev/null +++ b/app/db/seed.py @@ -0,0 +1,125 @@ +from app.db.session import SessionLocal +from app.db.models.roles import Role +from app.db.models.permissions import Permission +from app.db.models import Rule, User +from app.utils.security import hash_password + +import os +from dotenv import load_dotenv +from sqlalchemy import text + +load_dotenv() # Load environment variables from .env file + +def clear_tables(db): + # Truncate with restart identity to reset auto-increment IDs + db.execute(text("TRUNCATE TABLE rules, permissions, roles, users RESTART IDENTITY CASCADE")) + db.commit() + + +def seed_roles(db): + roles = [ + Role(name="admin"), + Role(name="customer"), + # add more roles if needed + ] + for role in roles: + existing = db.query(Role).filter(Role.name == role.name).first() + if not existing: + db.add(role) + db.commit() + + +def seed_permissions(db): + permissions = [ + # User related permissions + Permission(name="can_create_user"), + Permission(name="can_list_users"), + Permission(name="can_get_user"), + Permission(name="can_update_user"), + Permission(name="can_delete_user"), + + Permission(name="can_get_own_profile"), + Permission(name="can_update_own_profile"), + ] + for perm in permissions: + existing = db.query(Permission).filter(Permission.name == perm.name).first() + if not existing: + db.add(perm) + db.commit() + + +def seed_rules(db): + # Fetch roles + admin_role = db.query(Role).filter(Role.name == "admin").first() + customer_role = db.query(Role).filter(Role.name == "customer").first() + + # Fetch permissions + can_create_user = db.query(Permission).filter(Permission.name == "can_create_user").first() + can_list_users = db.query(Permission).filter(Permission.name == "can_list_users").first() + can_get_user = db.query(Permission).filter(Permission.name == "can_get_user").first() + can_update_user = db.query(Permission).filter(Permission.name == "can_update_user").first() + can_delete_user = db.query(Permission).filter(Permission.name == "can_delete_user").first() + + can_get_own_profile = db.query(Permission).filter(Permission.name == "can_get_own_profile").first() + can_update_own_profile = db.query(Permission).filter(Permission.name == "can_update_own_profile").first() + + rules = [ + # Admin permissions - full user management + Rule(role_name=admin_role.name, permission_name=can_create_user.name), + Rule(role_name=admin_role.name, permission_name=can_list_users.name), + Rule(role_name=admin_role.name, permission_name=can_get_user.name), + Rule(role_name=admin_role.name, permission_name=can_update_user.name), + Rule(role_name=admin_role.name, permission_name=can_delete_user.name), + + # Customer permissions - maybe limited or none here + Rule(role_name=customer_role.name, permission_name=can_get_own_profile.name), + Rule(role_name=customer_role.name, permission_name=can_update_own_profile.name), + ] + + for rule in rules: + existing = db.query(Rule).filter( + Rule.role_name == rule.role_name, + Rule.permission_name == rule.permission_name + ).first() + if not existing: + db.add(rule) + + db.commit() + + +def seed_admin_user(db): + admin_role = db.query(Role).filter(Role.name == "admin").first() + existing_admin = db.query(User).filter(User.role_name == admin_role.name).first() + if existing_admin: + print("Admin user already exists, skipping creation.") + return + + admin_username = os.getenv("ADMIN_USERNAME", "admin") + admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com") + admin_password = os.getenv("ADMIN_PASSWORD", "adminpassword123") + + admin_user = User( + username=admin_username, + email=admin_email, + hashed_password=hash_password(admin_password), + role_name=admin_role.name + ) + db.add(admin_user) + db.commit() + print("Admin user created.") + + +def seed_all(): + db = SessionLocal() + try: + clear_tables(db) + seed_roles(db) + seed_permissions(db) + seed_rules(db) + seed_admin_user(db) + finally: + db.close() + + +if __name__ == "__main__": + seed_all() diff --git a/app/db/session.py b/app/db/session.py index 8390218..db45ea0 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,10 +1,11 @@ -# app/database/session.py - import os +from dotenv import load_dotenv from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session from typing import Generator +load_dotenv() + DB_HOST = os.getenv("DB_HOST", "localhost") DB_PORT = os.getenv("DB_PORT", "5432") DB_USER = os.getenv("POSTGRES_USER", "postgres") diff --git a/app/routers/auth.py b/app/routers/auth.py index 5b83fb7..8c9c617 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -2,21 +2,63 @@ from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.db.session import get_db -from app.services.auth import authenticate_user, create_token_for_user +from app.services.auth import authenticate_user, get_permissions_for_role +from app.utils.jwt import create_access_token, create_refresh_token, decode_token +from app.db.models import User +from app.validators.auth import RefreshTokenRequest router = APIRouter() -@router.post("/token") -def login_for_access_token( - db: Session = Depends(get_db), - form_data: OAuth2PasswordRequestForm = Depends() +@router.post("/login") +def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) ): user = authenticate_user(db, form_data.username, form_data.password) if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - token = create_token_for_user(user) - return {"access_token": token, "token_type": "bearer"} + raise HTTPException(status_code=401, detail="Invalid credentials") + + # Fetch permissions for user's role from DB + permissions = get_permissions_for_role(db, user.role_name) + + user_data = { + "id": user.id, + "role": user.role_name, + "permissions": permissions, + } + + access_token = create_access_token(user_data) + refresh_token = create_refresh_token(user_data) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } + +@router.post("/refresh") +def refresh_token( + data: RefreshTokenRequest, + db: Session = Depends(get_db) +): + payload = decode_token(data.refresh_token) + + if payload.get("type") != "refresh": + raise HTTPException(status_code=400, detail="Invalid token type") + + user_id = int(payload.get("sub")) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + permissions = get_permissions_for_role(db, user.role_name) + + user_data = { + "id": user.id, + "role": user.role_name, + "permissions": permissions, + } + + new_access_token = create_access_token(user_data) + return {"access_token": new_access_token, "token_type": "bearer"} diff --git a/app/routers/users.py b/app/routers/users.py index c6cc938..044fa6e 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,8 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, HTTPException, status, Depends from fastapi.responses import JSONResponse +from typing import List + from sqlalchemy.orm import Session from app.db.session import get_db -from app.validators.users import UserCreate, UserUpdate, UserOut, TokenData + +from app.validators.users import UserCreate, UserUpdate, UserOut +from app.validators.auth import TokenData + from app.services.users import ( create_user_service, list_users_service, @@ -10,16 +15,19 @@ delete_user_service, update_user_service, ) -from typing import List -from app.utils.jwt import get_current_user, admin_required + +from app.utils.jwt import ( + get_current_user, + permission_required, +) router = APIRouter() -@router.post("/", summary="Create a new user (Admin only)", response_model=UserOut) +@router.post("/", summary="Create a new user", response_model=UserOut) def create_user( user_in: UserCreate, db: Session = Depends(get_db), - current_user: TokenData = Depends(admin_required), + current_user: TokenData = Depends(permission_required("create_user")), ): try: user = create_user_service(db, user_in) @@ -33,10 +41,10 @@ def create_user( detail="Unexpected error occurred.", ) - -@router.get("/", summary="List all users (Admin only)", response_model=List[UserOut]) +@router.get("/", summary="List all users", response_model=List[UserOut]) def list_users( - db: Session = Depends(get_db), current_user: TokenData = Depends(admin_required) + db: Session = Depends(get_db), + current_user: TokenData = Depends(permission_required("list_users")), ): try: users = list_users_service(db) @@ -48,10 +56,10 @@ def list_users( detail="Unexpected error occurred.", ) - @router.get("/me", summary="Get current user's profile", response_model=UserOut) def get_me( - db: Session = Depends(get_db), current_user: TokenData = Depends(get_current_user) + db: Session = Depends(get_db), + current_user: TokenData = Depends(get_current_user), ): try: user = get_user_service(db, current_user.user_id) @@ -65,7 +73,6 @@ def get_me( detail="Unexpected error occurred.", ) - @router.put("/me", summary="Update current user's profile", response_model=UserOut) def update_me( user_update: UserUpdate, @@ -84,15 +91,13 @@ def update_me( detail="Unexpected error occurred.", ) - @router.get("/{user_id}", summary="Get user by ID", response_model=UserOut) def get_user( user_id: int, db: Session = Depends(get_db), current_user: TokenData = Depends(get_current_user), ): - # Admin or user themself allowed - if current_user.role != "admin" and current_user.user_id != user_id: + if "get_any_user" not in current_user.permissions and current_user.user_id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.", @@ -109,7 +114,6 @@ def get_user( detail="Unexpected error occurred.", ) - @router.put("/{user_id}", summary="Update user by ID", response_model=UserOut) def update_user( user_id: int, @@ -117,8 +121,7 @@ def update_user( db: Session = Depends(get_db), current_user: TokenData = Depends(get_current_user), ): - # Admin or user themself allowed - if current_user.role != "admin" and current_user.user_id != user_id: + if "update_any_user" not in current_user.permissions and current_user.user_id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.", @@ -135,12 +138,11 @@ def update_user( detail="Unexpected error occurred.", ) - @router.delete("/{user_id}", summary="Delete user by ID", status_code=200) def delete_user( user_id: int, db: Session = Depends(get_db), - current_user: TokenData = Depends(admin_required), + current_user: TokenData = Depends(permission_required("delete_user")), ): try: delete_user_service(db, user_id) @@ -156,9 +158,10 @@ def delete_user( detail="Unexpected error occurred.", ) -@router.get("/{user_id}/orders", summary="List orders by user (Admin only)") -def list_user_orders( - user_id: int, current_user: TokenData = Depends(admin_required) -): - # Implement orders fetching logic here, admin only - pass + +# @router.get("/{user_id}/orders", summary="List orders by user (Admin only)") +# def list_user_orders( +# user_id: int, current_user: TokenData = Depends(admin_required) +# ): +# # Implement orders fetching logic here, admin only +# pass diff --git a/app/services/auth.py b/app/services/auth.py index 6de5b8c..587b3ac 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,26 +1,38 @@ -from passlib.context import CryptContext from datetime import timedelta from app.utils.jwt import create_access_token -from app.services.users import get_user_by_username - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -def hash_password(password: str) -> str: - return pwd_context.hash(password) - -def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) +from app.db.crud.auth import get_user_by_username, get_permissions_for_role +from sqlalchemy.orm import Session +from app.db.models import Rule +from datetime import timedelta +from app.utils.security import verify_password def authenticate_user(db, username: str, password: str): user = get_user_by_username(db, username) if not user: + return False if not verify_password(password, user.hashed_password): return False return user -def create_token_for_user(user): - token_data = {"user_id": user.id, "role": user.role} - access_token_expires = timedelta(minutes=60) - token = create_access_token(token_data, expires_delta=access_token_expires) - return token + +def get_permissions_for_role(db: Session, role_name: str) -> list[str]: + permissions = ( + db.query(Rule.permission_name) + .filter(Rule.role_name == role_name) + .all() + ) + return [perm.permission_name for perm in permissions] + +def create_token_for_user(db: Session, user): + permissions = get_permissions_for_role(db, user.role_name) + + token_data = { + "sub": str(user.id), + "role": user.role_name, + "permissions": permissions, + } + + expires_delta = timedelta(minutes=15) + access_token = create_access_token(token_data, expires_delta) + return access_token diff --git a/app/services/users.py b/app/services/users.py index f0a1f3b..194ef3d 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -2,7 +2,7 @@ from app.validators.users import UserCreate, UserUpdate from app.db.crud.users import get_user_by_email, get_user_by_username, create_user as db_create_user, get_all_users, get_user_by_id, delete_user_by_id, update_user_db from fastapi import HTTPException, status -from app.utils.hashing import hash_password +from app.utils.security import hash_password def create_user_service(db: Session, user_in: UserCreate): # Check if user exists by email or username diff --git a/app/utils/jwt.py b/app/utils/jwt.py index 336f30e..7a7af61 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -1,47 +1,77 @@ import os +import datetime +import jwt from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer -import jwt from jwt import PyJWTError -from app.validators.users import TokenData +from app.validators.auth import TokenData +from typing import Callable -JWT_SECRET = os.getenv("JWT_SECRET", "your-secret") # Use env var in prod +JWT_SECRET = os.getenv("JWT_SECRET", "your-secret") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = 15 +REFRESH_TOKEN_EXPIRE_DAYS = 7 -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # URL for token endpoint +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") -def create_access_token(data: dict, expires_delta=None): - import datetime +def create_token(data: dict, expires_delta: datetime.timedelta) -> str: to_encode = data.copy() - if expires_delta: - expire = datetime.datetime.utcnow() + expires_delta - else: - expire = datetime.datetime.utcnow() + datetime.timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) - return encoded_jwt + to_encode["exp"] = datetime.datetime.utcnow() + expires_delta + return jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) -def get_current_user(token: str = Depends(oauth2_scheme)) -> TokenData: - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, +def create_access_token(user: dict) -> str: + return create_token( + data={ + "sub": str(user["id"]), + "role": user["role"], + "permissions": user.get("permissions", []), + "type": "access" + }, + expires_delta=datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) + + +def create_refresh_token(user: dict) -> str: + return create_token( + data={ + "sub": str(user["id"]), + "type": "refresh" + }, + expires_delta=datetime.timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + ) + +def decode_token(token: str) -> dict: try: payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - user_id: int = payload.get("user_id") - role: str = payload.get("role") - if user_id is None or role is None: - raise credentials_exception - token_data = TokenData(user_id=user_id, role=role) + return payload except PyJWTError: - raise credentials_exception - return token_data - -def admin_required(current_user: TokenData = Depends(get_current_user)): - if current_user.role != "admin": raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin privileges required.", + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, ) - return current_user + +def get_current_user(token: str = Depends(oauth2_scheme)) -> TokenData: + payload = decode_token(token) + if payload.get("type") != "access": + raise HTTPException(status_code=400, detail="Invalid token type") + + user_id = int(payload.get("sub")) + role = payload.get("role") + permissions = payload.get("permissions", []) + + if user_id is None or role is None: + raise HTTPException(status_code=401, detail="Invalid token payload") + + return TokenData(user_id=user_id, role=role, permissions=permissions) + + +def permission_required(permission: str) -> Callable: + def _permission_dependency(current_user: TokenData = Depends(get_current_user)) -> TokenData: + if permission not in current_user.permissions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission '{permission}' required." + ) + return current_user + return _permission_dependency \ No newline at end of file diff --git a/app/utils/hashing.py b/app/utils/security.py similarity index 58% rename from app/utils/hashing.py rename to app/utils/security.py index 26d57cf..f57fe04 100644 --- a/app/utils/hashing.py +++ b/app/utils/security.py @@ -5,3 +5,5 @@ def hash_password(password: str) -> str: return pwd_context.hash(password) +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) \ No newline at end of file diff --git a/app/validators/auth.py b/app/validators/auth.py new file mode 100644 index 0000000..09fc1ac --- /dev/null +++ b/app/validators/auth.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel +from typing import List + +class TokenData(BaseModel): + user_id: int + role: str + permissions: List[str] + + +class RefreshTokenRequest(BaseModel): + refresh_token: str diff --git a/app/validators/users.py b/app/validators/users.py index d061201..c8a12e7 100644 --- a/app/validators/users.py +++ b/app/validators/users.py @@ -23,9 +23,4 @@ class UserOut(BaseModel): updated_at: datetime class Config: - orm_mode = True - - -class TokenData(BaseModel): - user_id: int - role: str \ No newline at end of file + orm_mode = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ad680d2..7fc9119 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +alembic==1.16.1 annotated-types==0.7.0 anyio==4.9.0 bcrypt==4.3.0 @@ -9,15 +10,19 @@ fastapi==0.115.12 greenlet==3.2.2 h11==0.16.0 idna==3.10 +Mako==1.3.10 +MarkupSafe==3.0.2 passlib==1.7.4 psycopg2-binary==2.9.10 pydantic==2.11.5 pydantic_core==2.33.2 PyJWT==2.10.1 python-dotenv==1.1.0 +python-multipart==0.0.20 sniffio==1.3.1 SQLAlchemy==2.0.41 starlette==0.46.2 +tomli==2.2.1 typing-inspection==0.4.1 typing_extensions==4.13.2 uvicorn==0.34.2