Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/api/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions apps/api/core/dependencies/auth.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion apps/api/core/security/hashing.py
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 42 additions & 1 deletion apps/api/core/security/jwt.py
Original file line number Diff line number Diff line change
@@ -1 +1,42 @@
# TODO: Implement JWT encoding and decoding logic
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:
"""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
11 changes: 9 additions & 2 deletions apps/api/infrastructure/db/base.py
Original file line number Diff line number Diff line change
@@ -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())

1 change: 1 addition & 0 deletions apps/api/infrastructure/db/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions apps/api/infrastructure/db/models.py
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
23 changes: 23 additions & 0 deletions apps/api/modules/auth/auth_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 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:
"""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
109 changes: 106 additions & 3 deletions apps/api/modules/auth/router.py
Original file line number Diff line number Diff line change
@@ -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"}
14 changes: 12 additions & 2 deletions apps/api/modules/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading