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
7 changes: 6 additions & 1 deletion .github/workflows/release-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,13 @@ jobs:
GHCR_TOKEN: ${{ secrets.DEPLOY_GHCR_TOKEN }}
GHCR_USER: ${{ github.repository_owner }}
IMAGE_TAG: ${{ needs.release.outputs.version }}
GRAFANA_ADMIN_USER: ${{ secrets.GRAFANA_ADMIN_USER }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG
envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG,GRAFANA_ADMIN_USER,GRAFANA_ADMIN_PASSWORD
command_timeout: 10m
script: |
set -e
Expand All @@ -138,6 +140,9 @@ jobs:

export IMAGE_TAG="$IMAGE_TAG"
export COMPOSE_PROFILES=observability
export GRAFANA_ROOT_URL="https://grafana.integr8scode.cc/"
export GRAFANA_ADMIN_USER="$GRAFANA_ADMIN_USER"
export GRAFANA_ADMIN_PASSWORD="$GRAFANA_ADMIN_PASSWORD"
docker compose pull
docker compose up -d --remove-orphans --no-build --wait --wait-timeout 180

Expand Down
152 changes: 26 additions & 126 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
from datetime import timedelta

import structlog
from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm

from app.core.security import SecurityService
from app.core.utils import get_client_ip
from app.db.repositories import UserRepository
from app.domain.enums import UserRole
from app.domain.user import DomainUserCreate
from app.schemas_pydantic.common import ErrorResponse
from app.schemas_pydantic.user import (
LoginResponse,
Expand All @@ -19,8 +13,6 @@
UserResponse,
)
from app.services.auth_service import AuthService
from app.services.login_lockout import LoginLockoutService
from app.services.runtime_settings import RuntimeSettingsLoader

router = APIRouter(prefix="/auth", tags=["authentication"], route_class=DishkaRoute)

Expand All @@ -36,10 +28,7 @@
async def login(
request: Request,
response: Response,
user_repo: FromDishka[UserRepository],
security_service: FromDishka[SecurityService],
runtime_settings: FromDishka[RuntimeSettingsLoader],
lockout_service: FromDishka[LoginLockoutService],
auth_service: FromDishka[AuthService],
logger: FromDishka[structlog.stdlib.BoundLogger],
form_data: OAuth2PasswordRequestForm = Depends(),
) -> LoginResponse:
Expand All @@ -52,75 +41,18 @@ async def login(
user_agent=request.headers.get("user-agent"),
)

if await lockout_service.check_locked(form_data.username):
raise HTTPException(
status_code=423,
detail="Account temporarily locked due to too many failed attempts",
)

user = await user_repo.get_user(form_data.username)

if not user:
logger.warning(
"Login failed - user not found",
username=form_data.username,
client_ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
)
locked = await lockout_service.record_failed_attempt(form_data.username)
if locked:
raise HTTPException(
status_code=423,
detail="Account locked due to too many failed attempts",
)
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)

if not security_service.verify_password(form_data.password, user.hashed_password):
logger.warning(
"Login failed - invalid password",
username=form_data.username,
client_ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
)
locked = await lockout_service.record_failed_attempt(form_data.username)
if locked:
raise HTTPException(
status_code=423,
detail="Account locked due to too many failed attempts",
)
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)

await lockout_service.clear_attempts(form_data.username)

effective = await runtime_settings.get_effective_settings()
session_timeout = effective.session_timeout_minutes

logger.info(
"Login successful",
username=user.username,
client_ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
token_expires_in_minutes=session_timeout,
result = await auth_service.login(
form_data.username,
form_data.password,
get_client_ip(request),
request.headers.get("user-agent"),
)

access_token_expires = timedelta(minutes=session_timeout)
access_token = security_service.create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)

csrf_token = security_service.generate_csrf_token(access_token)

# --8<-- [start:login_cookies]
response.set_cookie(
key="access_token",
value=access_token,
max_age=session_timeout * 60, # Convert to seconds
value=result.access_token,
max_age=result.session_timeout_minutes * 60,
httponly=True,
secure=True, # HTTPS only
samesite="strict", # CSRF protection
Expand All @@ -129,8 +61,8 @@ async def login(

response.set_cookie(
key="csrf_token",
value=csrf_token,
max_age=session_timeout * 60,
value=result.csrf_token,
max_age=result.session_timeout_minutes * 60,
httponly=False, # JavaScript needs to read this
secure=True,
samesite="strict",
Expand All @@ -143,9 +75,9 @@ async def login(

return LoginResponse(
message="Login successful",
username=user.username,
role=user.role,
csrf_token=csrf_token,
username=result.username,
role=result.role,
csrf_token=result.csrf_token,
)


Expand All @@ -160,9 +92,7 @@ async def login(
async def register(
request: Request,
user: UserCreate,
user_repo: FromDishka[UserRepository],
security_service: FromDishka[SecurityService],
runtime_settings: FromDishka[RuntimeSettingsLoader],
auth_service: FromDishka[AuthService],
logger: FromDishka[structlog.stdlib.BoundLogger],
) -> UserResponse:
"""Register a new user account."""
Expand All @@ -174,37 +104,12 @@ async def register(
user_agent=request.headers.get("user-agent"),
)

effective = await runtime_settings.get_effective_settings()
min_len = effective.password_min_length
if len(user.password) < min_len:
raise HTTPException(status_code=400, detail=f"Password must be at least {min_len} characters")

db_user = await user_repo.get_user(user.username)
if db_user:
logger.warning(
"Registration failed - username taken",
username=user.username,
client_ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
)
raise HTTPException(status_code=409, detail="Username already registered")

hashed_password = security_service.get_password_hash(user.password)
create_data = DomainUserCreate(
username=user.username,
email=user.email,
hashed_password=hashed_password,
role=UserRole.USER,
is_active=True,
is_superuser=False,
)
created_user = await user_repo.create_user(create_data)

logger.info(
"Registration successful",
username=created_user.username,
client_ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
created_user = await auth_service.register(
user.username,
user.email,
user.password,
get_client_ip(request),
request.headers.get("user-agent"),
)

return UserResponse.model_validate(created_user)
Expand Down Expand Up @@ -238,6 +143,7 @@ async def get_current_user_profile(
async def logout(
request: Request,
response: Response,
auth_service: FromDishka[AuthService],
logger: FromDishka[structlog.stdlib.BoundLogger],
) -> MessageResponse:
"""Log out and clear session cookies."""
Expand All @@ -248,17 +154,11 @@ async def logout(
user_agent=request.headers.get("user-agent"),
)

# Clear the httpOnly cookie
response.delete_cookie(
key="access_token",
path="/",
)
token = request.cookies.get("access_token")
await auth_service.publish_logout_event(token)

# Clear the CSRF cookie
response.delete_cookie(
key="csrf_token",
path="/",
)
response.delete_cookie(key="access_token", path="/")
response.delete_cookie(key="csrf_token", path="/")

logger.info(
"Logout successful",
Expand Down
3 changes: 3 additions & 0 deletions backend/app/core/exceptions/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fastapi.responses import JSONResponse

from app.domain.exceptions import (
AccountLockedError,
ConflictError,
DomainError,
ForbiddenError,
Expand Down Expand Up @@ -40,6 +41,8 @@ def _map_to_status_code(exc: DomainError) -> int:
return 403
if isinstance(exc, InvalidStateError):
return 400
if isinstance(exc, AccountLockedError):
return 423
if isinstance(exc, InfrastructureError):
return 500
return 500
Expand Down
2 changes: 0 additions & 2 deletions backend/app/core/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from app.core.metrics.dlq import DLQMetrics
from app.core.metrics.events import EventMetrics
from app.core.metrics.execution import ExecutionMetrics
from app.core.metrics.health import HealthMetrics
from app.core.metrics.kubernetes import KubernetesMetrics
from app.core.metrics.notifications import NotificationMetrics
from app.core.metrics.rate_limit import RateLimitMetrics
Expand All @@ -20,7 +19,6 @@
"DLQMetrics",
"EventMetrics",
"ExecutionMetrics",
"HealthMetrics",
"KubernetesMetrics",
"NotificationMetrics",
"RateLimitMetrics",
Expand Down
35 changes: 1 addition & 34 deletions backend/app/core/metrics/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@


class ConnectionMetrics(BaseMetrics):
"""Metrics for SSE connections and event bus."""
"""Metrics for SSE connections."""

def _create_instruments(self) -> None:
self.sse_active_connections = self._meter.create_up_down_counter(
Expand All @@ -23,19 +23,6 @@ def _create_instruments(self) -> None:
unit="1",
)

self.sse_shutdown_duration = self._meter.create_histogram(
name="sse.shutdown.duration", description="Time taken for SSE shutdown phases in seconds", unit="s"
)

# Event bus metrics
self.event_bus_subscribers = self._meter.create_up_down_counter(
name="event.bus.subscribers", description="Number of active event bus subscribers by pattern", unit="1"
)

self.event_bus_subscriptions = self._meter.create_up_down_counter(
name="event.bus.subscriptions.total", description="Total number of event bus subscriptions", unit="1"
)

def increment_sse_connections(self, endpoint: str = "default") -> None:
self.sse_active_connections.add(1, attributes={"endpoint": endpoint})

Expand All @@ -50,23 +37,3 @@ def record_sse_connection_duration(self, duration_seconds: float, endpoint: str)

def update_sse_draining_connections(self, delta: int) -> None:
self.sse_draining_connections.add(delta)

def record_sse_shutdown_duration(self, duration_seconds: float, phase: str) -> None:
self.sse_shutdown_duration.record(duration_seconds, attributes={"phase": phase})

def update_sse_shutdown_duration(self, duration_seconds: float, phase: str) -> None:
self.sse_shutdown_duration.record(duration_seconds, attributes={"phase": phase})

def increment_event_bus_subscriptions(self) -> None:
self.event_bus_subscriptions.add(1)

def decrement_event_bus_subscriptions(self, count: int = 1) -> None:
self.event_bus_subscriptions.add(-count)

def update_event_bus_subscribers(self, count: int, pattern: str) -> None:
"""Update the count of event bus subscribers for a specific pattern."""
# This tracks the current number of subscribers for a pattern
# We need to track the delta from the previous value
# Since we can't store state in metrics, we record the absolute value
# The metric system will handle the up/down nature
self.event_bus_subscribers.add(count, attributes={"pattern": pattern})
Loading
Loading