import re from datetime import date, datetime, timedelta, timezone from uuid import UUID import jwt from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pwdlib import PasswordHash from pydantic import EmailStr, TypeAdapter, ValidationError from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.database import get_postgres_session from app.models import Account from app.schemas import ( AccountRegister, AccountRead, AuthResponse, InviteAcceptRequest, InvitePreview, LoginRequest, PasswordChangeRequest, ) from app.services.member_invitations import get_valid_invitation from app.services.domain_policy import assert_company_domain_allowed from app.services.domain_provisioning import account_is_domain_owner from app.services.domain_provisioning import ( get_domain_by_name, provision_mail_domain, ) router = APIRouter(prefix="/auth", tags=["authentication"]) password_hash = PasswordHash.recommended() dummy_password_hash = password_hash.hash("Agmail timing protection value") bearer_scheme = HTTPBearer(auto_error=False) settings = get_settings() email_adapter = TypeAdapter(EmailStr) domain_pattern = re.compile( r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+" r"[a-z]{2,63}$" ) def normalize_phone(value: str) -> str: prefix = "+" if value.strip().startswith("+") else "" return prefix + "".join(character for character in value if character.isdigit()) def validate_password(value: str) -> None: if not ( re.search(r"[a-z]", value) and re.search(r"[A-Z]", value) and re.search(r"\d", value) ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Password must include uppercase, lowercase, and a number.", ) def build_email(payload: AccountRegister) -> tuple[str, str | None]: domain = settings.platform_domain company_domain = None if payload.account_type == "company": if not payload.company_domain: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Company domain is required for company accounts.", ) try: domain = assert_company_domain_allowed( payload.company_domain, settings.platform_domain, ) except ValueError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error), ) from error company_domain = domain email = f"{payload.email_name.lower()}@{domain}" try: return str(email_adapter.validate_python(email)), company_domain except ValidationError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="The resulting email address is invalid.", ) from error def create_access_token(account: Account) -> str: expires_at = datetime.now(timezone.utc) + timedelta( minutes=settings.access_token_minutes ) return jwt.encode( {"sub": str(account.id), "email": account.email, "exp": expires_at}, settings.jwt_secret, algorithm="HS256", ) async def account_read( session: AsyncSession, account: Account, ) -> AccountRead: is_owner = await account_is_domain_owner( session, account.id, account.company_domain, ) return AccountRead.model_validate(account).model_copy( update={"is_domain_owner": is_owner}, ) async def get_current_account( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), session: AsyncSession = Depends(get_postgres_session), ) -> Account: unauthorized = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Your session is invalid or has expired.", headers={"WWW-Authenticate": "Bearer"}, ) if credentials is None or credentials.scheme.lower() != "bearer": raise unauthorized try: claims = jwt.decode( credentials.credentials, settings.jwt_secret, algorithms=["HS256"], options={"require": ["sub", "exp"]}, ) account_id = UUID(claims["sub"]) except (jwt.PyJWTError, KeyError, TypeError, ValueError): raise unauthorized account = await session.get(Account, account_id) if account is None: raise unauthorized return account @router.post( "/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED, ) async def register( payload: AccountRegister, session: AsyncSession = Depends(get_postgres_session), ) -> AuthResponse: if payload.date_of_birth >= date.today(): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Date of birth must be in the past.", ) validate_password(payload.password) email, company_domain = build_email(payload) phone_number = normalize_phone(payload.phone_number) existing = await session.scalar( select(Account).where( or_(Account.email == email, Account.phone_number == phone_number) ) ) if existing: detail = ( "That email address is already registered." if existing.email == email else "That phone number is already registered." ) raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) account = Account( account_type=payload.account_type, email=email, first_name=payload.first_name.strip(), last_name=payload.last_name.strip(), date_of_birth=payload.date_of_birth, phone_number=phone_number, company_domain=company_domain, org_role="owner" if company_domain else "user", member_status="active", password_hash=password_hash.hash(payload.password), ) session.add(account) try: await session.flush() if company_domain: existing_domain = await get_domain_by_name(session, company_domain) if existing_domain is None: await provision_mail_domain( session, domain_name=company_domain, domain_type="company", account_id=account.id, ) await session.commit() except IntegrityError as error: await session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="An account with those details already exists.", ) from error await session.refresh(account) return AuthResponse( access_token=create_access_token(account), account=await account_read(session, account), ) @router.post("/login", response_model=AuthResponse) async def login( payload: LoginRequest, session: AsyncSession = Depends(get_postgres_session), ) -> AuthResponse: account = await session.scalar( select(Account).where(Account.email == str(payload.email).lower()) ) password_is_valid = password_hash.verify( payload.password, account.password_hash if account is not None else dummy_password_hash, ) if account is None or not password_is_valid: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password.", ) if account.member_status == "invited": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Accept your invitation email to set your password before signing in.", ) return AuthResponse( access_token=create_access_token(account), account=await account_read(session, account), ) @router.get("/invites/{token}", response_model=InvitePreview) async def preview_invite( token: str, session: AsyncSession = Depends(get_postgres_session), ) -> InvitePreview: try: invitation, account = await get_valid_invitation(session, token) except ValueError as error: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(error), ) from error manager_name = "Your administrator" if account.created_by_id: manager = await session.get(Account, account.created_by_id) if manager is not None: manager_name = f"{manager.first_name} {manager.last_name}".strip() return InvitePreview( work_email=account.email, first_name=account.first_name, organization=account.company_domain or settings.platform_domain, invited_by=manager_name, expires_at=invitation.expires_at, ) @router.post("/invites/{token}/accept", response_model=AuthResponse) async def accept_invite( token: str, payload: InviteAcceptRequest, session: AsyncSession = Depends(get_postgres_session), ) -> AuthResponse: validate_password(payload.password) try: invitation, account = await get_valid_invitation(session, token) except ValueError as error: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(error), ) from error account.password_hash = password_hash.hash(payload.password) account.member_status = "active" account.must_change_password = False invitation.used_at = datetime.now(timezone.utc) await session.commit() await session.refresh(account) return AuthResponse( access_token=create_access_token(account), account=await account_read(session, account), ) @router.post("/change-password", response_model=AccountRead) async def change_password( payload: PasswordChangeRequest, account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> AccountRead: validate_password(payload.new_password) if not password_hash.verify(payload.current_password, account.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect.", ) account.password_hash = password_hash.hash(payload.new_password) account.must_change_password = False if account.member_status == "invited": account.member_status = "active" await session.commit() return await account_read(session, account) @router.get("/me", response_model=AccountRead) async def get_me( account: Account = Depends(get_current_account), session: AsyncSession = Depends(get_postgres_session), ) -> AccountRead: return await account_read(session, account)