Improve auth flow

This commit is contained in:
Urtzi Alfaro
2025-07-19 17:49:03 +02:00
parent f3071c00bd
commit abc8b68ab4
16 changed files with 1437 additions and 572 deletions

View File

@@ -1,35 +1,31 @@
# services/auth/app/schemas/auth.py
"""
Authentication schemas
Authentication schemas
"""
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional
from datetime import datetime
from app.core.config import settings
from shared.utils.validation import validate_spanish_phone
class UserRegistration(BaseModel):
"""User registration schema"""
email: EmailStr
password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
password: str = Field(..., min_length=8)
full_name: str = Field(..., min_length=2, max_length=100)
phone: Optional[str] = None
language: str = Field(default="es", pattern="^(es|en)$")
@validator('password')
def validate_password(cls, v):
"""Validate password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('Password does not meet security requirements')
return v
@validator('phone')
def validate_phone(cls, v):
"""Validate phone number"""
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
"""Basic password validation"""
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not any(c.isupper() for c in v):
raise ValueError('Password must contain uppercase letter')
if not any(c.islower() for c in v):
raise ValueError('Password must contain lowercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain number')
return v
class UserLogin(BaseModel):
@@ -55,55 +51,29 @@ class UserResponse(BaseModel):
full_name: str
is_active: bool
is_verified: bool
tenant_id: Optional[str]
role: str
phone: Optional[str]
language: str
timezone: str
created_at: Optional[datetime]
created_at: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True
class PasswordChangeRequest(BaseModel):
"""Password change request schema"""
current_password: str
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
new_password: str = Field(..., min_length=8)
@validator('new_password')
def validate_new_password(cls, v):
"""Validate new password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('New password does not meet security requirements')
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
return v
class PasswordResetRequest(BaseModel):
"""Password reset request schema"""
email: EmailStr
class PasswordResetConfirm(BaseModel):
"""Password reset confirmation schema"""
token: str
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
@validator('new_password')
def validate_new_password(cls, v):
"""Validate new password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('New password does not meet security requirements')
return v
class UserUpdate(BaseModel):
"""User update schema"""
full_name: Optional[str] = Field(None, min_length=2, max_length=100)
phone: Optional[str] = None
language: Optional[str] = Field(None, pattern="^(es|en)$")
timezone: Optional[str] = None
tenant_id: Optional[str] = None
@validator('phone')
def validate_phone(cls, v):
"""Validate phone number"""
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
return v
class TokenVerificationResponse(BaseModel):
"""Token verification response for other services"""
user_id: str
email: str
is_active: bool
expires_at: datetime

View File

@@ -17,3 +17,5 @@ python-json-logger==2.0.4
pytz==2023.3
python-logstash==0.4.8
structlog==23.2.0
python-dotenv==1.0.0

View File

@@ -0,0 +1,167 @@
# services/tenant/app/api/tenants.py
"""
Tenant API endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
import structlog
from app.core.database import get_db
from app.schemas.tenants import (
BakeryRegistration, TenantResponse, TenantAccessResponse,
TenantUpdate, TenantMemberResponse
)
from app.services.tenant_service import TenantService
from shared.auth.decorators import require_authentication, get_current_user, get_current_tenant_id
logger = structlog.get_logger()
router = APIRouter()
@router.post("/bakeries", response_model=TenantResponse)
@require_authentication
async def register_bakery(
bakery_data: BakeryRegistration,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Register a new bakery/tenant"""
user = get_current_user(request)
try:
result = await TenantService.create_bakery(bakery_data, user["user_id"], db)
logger.info(f"Bakery registered: {bakery_data.name} by {user['email']}")
return result
except Exception as e:
logger.error(f"Bakery registration failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Bakery registration failed"
)
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
async def verify_tenant_access(
tenant_id: str,
user_id: str,
db: AsyncSession = Depends(get_db)
):
"""Verify if user has access to tenant - Called by Gateway"""
try:
access_info = await TenantService.verify_user_access(user_id, tenant_id, db)
return access_info
except Exception as e:
logger.error(f"Access verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access verification failed"
)
@router.get("/users/{user_id}/tenants", response_model=List[TenantResponse])
@require_authentication
async def get_user_tenants(
user_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Get all tenants accessible by user"""
current_user = get_current_user(request)
# Users can only see their own tenants
if current_user["user_id"] != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
try:
tenants = await TenantService.get_user_tenants(user_id, db)
return tenants
except Exception as e:
logger.error(f"Failed to get user tenants: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve tenants"
)
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
@require_authentication
async def get_tenant(
tenant_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Get tenant details"""
user = get_current_user(request)
# Verify user has access to tenant
access = await TenantService.verify_user_access(user["user_id"], tenant_id, db)
if not access.has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to tenant"
)
tenant = await TenantService.get_tenant_by_id(tenant_id, db)
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return tenant
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
@require_authentication
async def update_tenant(
tenant_id: str,
update_data: TenantUpdate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Update tenant information"""
user = get_current_user(request)
try:
result = await TenantService.update_tenant(tenant_id, update_data, user["user_id"], db)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Tenant update failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Tenant update failed"
)
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
@require_authentication
async def add_team_member(
tenant_id: str,
user_id: str,
role: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Add a team member to tenant"""
current_user = get_current_user(request)
try:
result = await TenantService.add_team_member(
tenant_id, user_id, role, current_user["user_id"], db
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Add team member failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add team member"
)

View File

@@ -1,5 +1,6 @@
# services/tenant/app/main.py
"""
uLutenant Service
Tenant Service FastAPI application
"""
import structlog
@@ -7,23 +8,27 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import database_manager
from app.core.database import engine
from app.api import tenants
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
# Setup logging
setup_logging("tenant-service", "INFO")
setup_logging("tenant-service", settings.LOG_LEVEL)
logger = structlog.get_logger()
# Create FastAPI app
app = FastAPI(
title="uLutenant Service",
description="uLutenant service for bakery forecasting",
version="1.0.0"
title="Tenant Management Service",
description="Multi-tenant bakery management service",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# Initialize metrics collector
metrics_collector = MetricsCollector("tenant-service")
# Initialize metrics
metrics_collector = MetricsCollector("tenant_service")
app.state.metrics_collector = metrics_collector
# CORS middleware
app.add_middleware(
@@ -34,18 +39,19 @@ app.add_middleware(
allow_headers=["*"],
)
# Include routers
app.include_router(tenants.router, prefix="/api/v1", tags=["tenants"])
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting uLutenant Service")
"""Initialize service on startup"""
logger.info("Starting Tenant Service...")
# Create database tables
await database_manager.create_tables()
# Start metrics server
metrics_collector.start_metrics_server(8080)
logger.info("uLutenant Service started successfully")
@app.on_event("shutdown")
async def shutdown_event():
"""Cleanup on shutdown"""
logger.info("Shutting down Tenant Service...")
await engine.dispose()
@app.get("/health")
async def health_check():
@@ -56,6 +62,11 @@ async def health_check():
"version": "1.0.0"
}
@app.get("/metrics")
async def metrics():
"""Prometheus metrics endpoint"""
return metrics_collector.generate_latest()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,73 @@
# services/tenant/app/models/tenants.py
"""
Tenant models for bakery management
"""
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from shared.database.base import Base
class Tenant(Base):
"""Tenant/Bakery model"""
__tablename__ = "tenants"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
subdomain = Column(String(100), unique=True)
business_type = Column(String(100), default="bakery")
# Location info
address = Column(Text, nullable=False)
city = Column(String(100), default="Madrid")
postal_code = Column(String(10), nullable=False)
latitude = Column(Float)
longitude = Column(Float)
# Contact info
phone = Column(String(20))
email = Column(String(255))
# Status
is_active = Column(Boolean, default=True)
subscription_tier = Column(String(50), default="basic")
# ML status
model_trained = Column(Boolean, default=False)
last_training_date = Column(DateTime)
# Ownership
owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name})>"
class TenantMember(Base):
"""Tenant membership model for team access"""
__tablename__ = "tenant_members"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Role and permissions
role = Column(String(50), default="member") # owner, admin, member, viewer
permissions = Column(Text) # JSON string of permissions
# Status
is_active = Column(Boolean, default=True)
invited_by = Column(UUID(as_uuid=True))
invited_at = Column(DateTime, default=datetime.utcnow)
joined_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<TenantMember(tenant_id={self.tenant_id}, user_id={self.user_id}, role={self.role})>"

View File

@@ -0,0 +1,83 @@
# services/tenant/app/schemas/tenants.py
"""
Tenant schemas
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import re
class BakeryRegistration(BaseModel):
"""Bakery registration schema"""
name: str = Field(..., min_length=2, max_length=200)
address: str = Field(..., min_length=10, max_length=500)
city: str = Field(default="Madrid", max_length=100)
postal_code: str = Field(..., pattern=r"^\d{5}$")
phone: str = Field(..., min_length=9, max_length=20)
business_type: str = Field(default="bakery")
@validator('phone')
def validate_spanish_phone(cls, v):
"""Validate Spanish phone number"""
# Remove spaces and common separators
phone = re.sub(r'[\s\-\(\)]', '', v)
# Spanish mobile: +34 6/7/8/9 + 8 digits
# Spanish landline: +34 9 + 8 digits
patterns = [
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
r'^(\+34|0034|34)?9\d{8}$', # Landline
]
if not any(re.match(pattern, phone) for pattern in patterns):
raise ValueError('Invalid Spanish phone number')
return v
@validator('business_type')
def validate_business_type(cls, v):
valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant']
if v not in valid_types:
raise ValueError(f'Business type must be one of: {valid_types}')
return v
class TenantResponse(BaseModel):
"""Tenant response schema"""
id: str
name: str
subdomain: Optional[str]
business_type: str
address: str
city: str
postal_code: str
phone: Optional[str]
is_active: bool
subscription_tier: str
model_trained: bool
last_training_date: Optional[datetime]
owner_id: str
created_at: datetime
class Config:
from_attributes = True
class TenantAccessResponse(BaseModel):
"""Tenant access verification response"""
has_access: bool
role: str
permissions: List[str]
class TenantMemberResponse(BaseModel):
"""Tenant member response"""
id: str
user_id: str
role: str
is_active: bool
joined_at: Optional[datetime]
class TenantUpdate(BaseModel):
"""Tenant update schema"""
name: Optional[str] = Field(None, min_length=2, max_length=200)
address: Optional[str] = Field(None, min_length=10, max_length=500)
phone: Optional[str] = None
business_type: Optional[str] = None

View File

@@ -0,0 +1,41 @@
# services/tenant/app/services/messaging.py
"""
Tenant service messaging for event publishing
"""
import structlog
from shared.messaging.rabbitmq import RabbitMQPublisher
logger = structlog.get_logger()
async def publish_tenant_created(tenant_id: str, owner_id: str, tenant_name: str):
"""Publish tenant created event"""
try:
publisher = RabbitMQPublisher()
await publisher.publish_event(
"tenant.created",
{
"tenant_id": tenant_id,
"owner_id": owner_id,
"tenant_name": tenant_name,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.created event: {e}")
async def publish_member_added(tenant_id: str, user_id: str, role: str):
"""Publish member added event"""
try:
publisher = RabbitMQPublisher()
await publisher.publish_event(
"tenant.member.added",
{
"tenant_id": tenant_id,
"user_id": user_id,
"role": role,
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to publish tenant.member.added event: {e}")