From 572dab96dbbdd91a486772d9cbd91fd003061fa9 Mon Sep 17 00:00:00 2001 From: itsjoxdev Date: Fri, 24 Jul 2026 18:09:39 +0300 Subject: [PATCH 1/2] feat(auth): implement authentication core services --- apps/api/core/config/settings.py | 8 ++-- apps/api/core/security/hashing.py | 15 +++++- apps/api/core/security/jwt.py | 42 +++++++++++++++- apps/api/infrastructure/db/base.py | 11 ++++- apps/api/infrastructure/db/migrations/env.py | 1 + .../versions/001_create_users_table.py | 48 +++++++++++++++++++ apps/api/infrastructure/db/models.py | 11 +++++ apps/api/modules/auth/auth_service.py | 21 ++++++++ apps/api/modules/auth/token_service.py | 37 ++++++++++++++ apps/api/modules/users/models.py | 20 +++++++- apps/api/modules/users/schemas.py | 30 +++++++++++- apps/api/pyproject.toml | 2 +- apps/api/requirements/base.txt | 2 +- 13 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 apps/api/infrastructure/db/migrations/versions/001_create_users_table.py create mode 100644 apps/api/infrastructure/db/models.py create mode 100644 apps/api/modules/auth/auth_service.py create mode 100644 apps/api/modules/auth/token_service.py diff --git a/apps/api/core/config/settings.py b/apps/api/core/config/settings.py index 4704f75..1963201 100644 --- a/apps/api/core/config/settings.py +++ b/apps/api/core/config/settings.py @@ -10,10 +10,10 @@ class Settings(BaseSettings): APP_ENV: str = "development" DEBUG: bool = Field(default=True, alias="APP_DEBUG") DATABASE_URL: str - SECRET_KEY: str = Field(alias="JWT_SECRET_KEY") - ALGORITHM: str = Field(default="HS256", alias="JWT_ALGORITHM") - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + JWT_SECRET_KEY: str + JWT_ALGORITHM: str = "HS256" + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 CORS_ORIGINS: list[str] | str = ["*"] model_config = SettingsConfigDict( diff --git a/apps/api/core/security/hashing.py b/apps/api/core/security/hashing.py index 9dd1b58..b6b947b 100644 --- a/apps/api/core/security/hashing.py +++ b/apps/api/core/security/hashing.py @@ -1 +1,14 @@ -# TODO: Implement password hashing logic (e.g., bcrypt) +import bcrypt + +def hash_password(password: str) -> str: + """Hashes a password using bcrypt directly.""" + pwd_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(pwd_bytes, salt) + return hashed.decode('utf-8') + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifies a plain password against a hashed password.""" + plain_bytes = plain_password.encode('utf-8') + hashed_bytes = hashed_password.encode('utf-8') + return bcrypt.checkpw(plain_bytes, hashed_bytes) diff --git a/apps/api/core/security/jwt.py b/apps/api/core/security/jwt.py index 968d267..773168a 100644 --- a/apps/api/core/security/jwt.py +++ b/apps/api/core/security/jwt.py @@ -1 +1,41 @@ -# TODO: Implement JWT encoding and decoding logic +from datetime import datetime, timedelta, timezone +from jose import jwt, JWTError +from typing import Any +from apps.api.core.config.settings import settings + +def _get_utcnow() -> datetime: + """Helper to get current UTC time.""" + return datetime.now(timezone.utc) + +def create_jwt_token( + subject: str | Any, + token_type: str, + expires_delta: timedelta +) -> str: + now = _get_utcnow() + expire = now + expires_delta + + to_encode = { + "sub": str(subject), + "exp": expire, + "iat": now, + "type": token_type + } + + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + return encoded_jwt + +def decode_jwt_token(token: str) -> dict[str, Any]: + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + return payload + except JWTError as e: + raise ValueError("Invalid or expired token") from e diff --git a/apps/api/infrastructure/db/base.py b/apps/api/infrastructure/db/base.py index f75ec44..aa60056 100644 --- a/apps/api/infrastructure/db/base.py +++ b/apps/api/infrastructure/db/base.py @@ -1,4 +1,11 @@ -from sqlalchemy.orm import DeclarativeBase +import uuid +from datetime import datetime +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import DateTime +from sqlalchemy.sql import func class Base(DeclarativeBase): - pass + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + diff --git a/apps/api/infrastructure/db/migrations/env.py b/apps/api/infrastructure/db/migrations/env.py index 0eac8e2..fbf8737 100644 --- a/apps/api/infrastructure/db/migrations/env.py +++ b/apps/api/infrastructure/db/migrations/env.py @@ -14,6 +14,7 @@ from apps.api.core.config.settings import settings # pyrefly: ignore [missing-import] from apps.api.infrastructure.db.base import Base +import apps.api.infrastructure.db.models # Import model registry so Alembic discovers all models config = context.config diff --git a/apps/api/infrastructure/db/migrations/versions/001_create_users_table.py b/apps/api/infrastructure/db/migrations/versions/001_create_users_table.py new file mode 100644 index 0000000..6a4c983 --- /dev/null +++ b/apps/api/infrastructure/db/migrations/versions/001_create_users_table.py @@ -0,0 +1,48 @@ +"""create users table + +Revision ID: 001_create_users_table +Revises: +Create Date: 2026-07-24 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '001_create_users_table' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create Role enum + role_enum = postgresql.ENUM('ADMIN', 'TECHNICIAN', 'EMPLOYEE', name='role', create_type=False) + role_enum.create(op.get_bind(), checkfirst=True) + + # Create users table + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('full_name', sa.String(length=150), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.Text(), nullable=False), + sa.Column('role', postgresql.ENUM('ADMIN', 'TECHNICIAN', 'EMPLOYEE', name='role', create_type=False), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_users_role'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + + role_enum = postgresql.ENUM('ADMIN', 'TECHNICIAN', 'EMPLOYEE', name='role', create_type=False) + role_enum.drop(op.get_bind(), checkfirst=True) diff --git a/apps/api/infrastructure/db/models.py b/apps/api/infrastructure/db/models.py new file mode 100644 index 0000000..17327a4 --- /dev/null +++ b/apps/api/infrastructure/db/models.py @@ -0,0 +1,11 @@ +""" +Model Registry +This module imports all SQLAlchemy models to ensure they are registered with the Base metadata. +Alembic imports this module in env.py to discover all tables. +""" +from apps.api.infrastructure.db.base import Base + +# Import all models below: +from apps.api.modules.users.models import User + +# Future models (Tickets, Dashboard, etc.) will be imported here. diff --git a/apps/api/modules/auth/auth_service.py b/apps/api/modules/auth/auth_service.py new file mode 100644 index 0000000..b0ae6a2 --- /dev/null +++ b/apps/api/modules/auth/auth_service.py @@ -0,0 +1,21 @@ +from apps.api.core.security.hashing import hash_password, verify_password as verify_pwd +from apps.api.modules.users.models import User + +class AuthService: + """Pure service for authentication logic, detached from HTTP requests and routes.""" + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + return verify_pwd(plain_password, hashed_password) + + @staticmethod + def hash_new_password(password: str) -> str: + return hash_password(password) + + @staticmethod + def authenticate_user(user: User | None, password: str) -> User | None: + if not user: + return None + if not AuthService.verify_password(password, user.password_hash): + return None + return user diff --git a/apps/api/modules/auth/token_service.py b/apps/api/modules/auth/token_service.py new file mode 100644 index 0000000..21a9a0c --- /dev/null +++ b/apps/api/modules/auth/token_service.py @@ -0,0 +1,37 @@ +from datetime import timedelta +from typing import Any +from apps.api.core.config.settings import settings +from apps.api.core.security.jwt import create_jwt_token, decode_jwt_token + +class TokenService: + """Pure service for handling access and refresh tokens without HTTP logic.""" + + @staticmethod + def create_access_token(subject: str | Any) -> str: + expires_delta = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + return create_jwt_token( + subject=subject, + token_type="access", + expires_delta=expires_delta + ) + + @staticmethod + def create_refresh_token(subject: str | Any) -> str: + expires_delta = timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) + return create_jwt_token( + subject=subject, + token_type="refresh", + expires_delta=expires_delta + ) + + @staticmethod + def decode_token(token: str) -> dict[str, Any]: + return decode_jwt_token(token) + + @staticmethod + def verify_token(token: str, expected_type: str) -> dict[str, Any]: + payload = TokenService.decode_token(token) + token_type = payload.get("type") + if token_type != expected_type: + raise ValueError(f"Invalid token type. Expected {expected_type}, got {token_type}") + return payload diff --git a/apps/api/modules/users/models.py b/apps/api/modules/users/models.py index 4c27593..a038a93 100644 --- a/apps/api/modules/users/models.py +++ b/apps/api/modules/users/models.py @@ -1 +1,19 @@ -# TODO: Define users database models (including roles e.g., Technician) +import enum +from sqlalchemy import String, Boolean, Enum, Text +from sqlalchemy.orm import Mapped, mapped_column +# pyrefly: ignore [missing-import] +from apps.api.infrastructure.db.base import Base + +class Role(str, enum.Enum): + ADMIN = "ADMIN" + TECHNICIAN = "TECHNICIAN" + EMPLOYEE = "EMPLOYEE" + +class User(Base): + __tablename__ = "users" + + full_name: Mapped[str] = mapped_column(String(150), nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[Role] = mapped_column(Enum(Role), index=True, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/apps/api/modules/users/schemas.py b/apps/api/modules/users/schemas.py index 96f2f14..b9e9f04 100644 --- a/apps/api/modules/users/schemas.py +++ b/apps/api/modules/users/schemas.py @@ -1,3 +1,29 @@ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr, Field +import uuid +from datetime import datetime +# pyrefly: ignore [missing-import] +from apps.api.modules.users.models import Role -# TODO: Define users Pydantic schemas +class UserBase(BaseModel): + full_name: str = Field(..., max_length=150) + email: EmailStr + role: Role + is_active: bool = True + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + +class UserRead(UserBase): + id: uuid.UUID + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class UserUpdate(BaseModel): + full_name: str | None = Field(None, max_length=150) + email: EmailStr | None = None + role: Role | None = None + is_active: bool | None = None + password: str | None = Field(None, min_length=8) diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index dc728ac..53cdcbb 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -13,7 +13,7 @@ sqlalchemy = "*" alembic = "*" asyncpg = "*" python-jose = {extras = ["cryptography"], version = "*"} -passlib = {extras = ["bcrypt"], version = "*"} +bcrypt = "*" # TODO: Add build dependencies [build-system] diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt index 47f3ad2..9e91f93 100644 --- a/apps/api/requirements/base.txt +++ b/apps/api/requirements/base.txt @@ -6,4 +6,4 @@ sqlalchemy alembic asyncpg python-jose[cryptography] -passlib[bcrypt] +bcrypt From 372b56e420431a041c2bf7d8d5100519b33a8665 Mon Sep 17 00:00:00 2001 From: itsjoxdev Date: Fri, 24 Jul 2026 18:30:58 +0300 Subject: [PATCH 2/2] feat(auth): complete authentication module --- apps/api/core/dependencies/auth.py | 51 ++++++++++++ apps/api/core/security/jwt.py | 1 + apps/api/main.py | 4 + apps/api/modules/auth/auth_service.py | 2 + apps/api/modules/auth/router.py | 109 ++++++++++++++++++++++++- apps/api/modules/auth/schemas.py | 14 +++- apps/api/modules/auth/token_service.py | 2 + 7 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 apps/api/core/dependencies/auth.py diff --git a/apps/api/core/dependencies/auth.py b/apps/api/core/dependencies/auth.py new file mode 100644 index 0000000..5bbb0b2 --- /dev/null +++ b/apps/api/core/dependencies/auth.py @@ -0,0 +1,51 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from apps.api.infrastructure.db.session import get_db +from apps.api.modules.auth.token_service import TokenService +from apps.api.modules.users.models import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = TokenService.verify_token(token, expected_type="access") + user_id_str: str | None = payload.get("sub") + if user_id_str is None: + raise credentials_exception + try: + user_id = uuid.UUID(user_id_str) + except ValueError: + raise credentials_exception + except Exception: + raise credentials_exception + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise credentials_exception + + return user + +async def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + return current_user diff --git a/apps/api/core/security/jwt.py b/apps/api/core/security/jwt.py index 773168a..29f3fa6 100644 --- a/apps/api/core/security/jwt.py +++ b/apps/api/core/security/jwt.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta, timezone from jose import jwt, JWTError from typing import Any +# pyrefly: ignore [missing-import] from apps.api.core.config.settings import settings def _get_utcnow() -> datetime: diff --git a/apps/api/main.py b/apps/api/main.py index 00e9fd1..e9a8820 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -6,6 +6,10 @@ # TODO: Include routers from modules # TODO: Add exception handlers +from apps.api.modules.auth.router import router as auth_router + +app.include_router(auth_router, prefix="/api/v1/auth") + @app.get("/") def read_root(): return {"message": "Welcome to TicketFlow API"} diff --git a/apps/api/modules/auth/auth_service.py b/apps/api/modules/auth/auth_service.py index b0ae6a2..5112bd2 100644 --- a/apps/api/modules/auth/auth_service.py +++ b/apps/api/modules/auth/auth_service.py @@ -1,4 +1,6 @@ +# pyrefly: ignore [missing-import] from apps.api.core.security.hashing import hash_password, verify_password as verify_pwd +# pyrefly: ignore [missing-import] from apps.api.modules.users.models import User class AuthService: diff --git a/apps/api/modules/auth/router.py b/apps/api/modules/auth/router.py index 0fea2f5..803773c 100644 --- a/apps/api/modules/auth/router.py +++ b/apps/api/modules/auth/router.py @@ -1,5 +1,108 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid -router = APIRouter(prefix="/auth", tags=["auth"]) +from apps.api.infrastructure.db.session import get_db +from apps.api.core.dependencies.auth import get_current_active_user +from apps.api.modules.auth.schemas import TokenResponse, RefreshRequest, LoginRequest +from apps.api.modules.users.schemas import UserCreate, UserRead +from apps.api.modules.users.models import User +from apps.api.modules.auth.auth_service import AuthService +from apps.api.modules.auth.token_service import TokenService -# TODO: Implement authentication endpoints +router = APIRouter(tags=["Auth"]) + +@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == user_in.email)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered" + ) + + hashed_password = AuthService.hash_new_password(user_in.password) + + new_user = User( + full_name=user_in.full_name, + email=user_in.email, + password_hash=hashed_password, + role=user_in.role, + is_active=user_in.is_active + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return new_user + +@router.post("/login", response_model=TokenResponse) +async def login( + request: LoginRequest, + db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(User).where(User.email == request.email)) + user = result.scalar_one_or_none() + + authenticated_user = AuthService.authenticate_user(user, request.password) + if not authenticated_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not authenticated_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + + access_token = TokenService.create_access_token(subject=str(authenticated_user.id)) + refresh_token = TokenService.create_refresh_token(subject=str(authenticated_user.id)) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer" + ) + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token(request: RefreshRequest, db: AsyncSession = Depends(get_db)): + try: + payload = TokenService.verify_token(request.refresh_token, expected_type="refresh") + user_id_str = payload.get("sub") + if not user_id_str: + raise ValueError("Invalid token subject") + user_id = uuid.UUID(user_id_str) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive" + ) + + access_token = TokenService.create_access_token(subject=str(user.id)) + + return TokenResponse( + access_token=access_token, + refresh_token=request.refresh_token, + token_type="bearer" + ) + +@router.get("/me", response_model=UserRead) +async def get_me(current_user: User = Depends(get_current_active_user)): + return current_user + +@router.post("/logout", status_code=status.HTTP_200_OK) +async def logout(): + return {"detail": "Successfully logged out"} diff --git a/apps/api/modules/auth/schemas.py b/apps/api/modules/auth/schemas.py index 7333718..b163cf4 100644 --- a/apps/api/modules/auth/schemas.py +++ b/apps/api/modules/auth/schemas.py @@ -1,3 +1,13 @@ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr, Field -# TODO: Define authentication Pydantic schemas (e.g., LoginRequest, TokenResponse) +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/apps/api/modules/auth/token_service.py b/apps/api/modules/auth/token_service.py index 21a9a0c..726ecbb 100644 --- a/apps/api/modules/auth/token_service.py +++ b/apps/api/modules/auth/token_service.py @@ -1,6 +1,8 @@ from datetime import timedelta from typing import Any +# pyrefly: ignore [missing-import] from apps.api.core.config.settings import settings +# pyrefly: ignore [missing-import] from apps.api.core.security.jwt import create_jwt_token, decode_jwt_token class TokenService: