agentic.email/backend/app/routers/auth.py

543 lines
18 KiB
Python

import logging
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 select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_mongo_database, get_postgres_session
from app.models import Account
from app.schemas import (
AccountProfileUpdate,
AccountRegister,
AccountRead,
AccountSecurityRead,
AccountSecurityUpdate,
AuthResponse,
InviteAcceptRequest,
InvitePreview,
LoginChallengeResponse,
LoginOtpResendRequest,
LoginOtpResendResponse,
LoginRequest,
PasswordChangeRequest,
PasswordResetRequest,
PasswordResetResponse,
SignupOtpSendRequest,
SignupOtpSendResponse,
)
from app.services.login_mfa import (
CHALLENGE_TTL_MINUTES,
account_requires_mfa,
create_login_challenge,
mfa_phone_parts,
resend_login_challenge_otp,
verify_login_challenge,
)
from app.services.session_policy import (
resolve_session_timeout_minutes,
validate_session_timeout_minutes,
)
from app.services.urmi_otp import parse_india_phone
from app.services.member_invitations import get_valid_invitation
from app.services.urmi_otp import (
INDIA_COUNTRY_CODE,
format_phone_e164,
send_signup_otp,
verify_signup_otp,
)
from app.services.welcome_email import send_welcome_email
logger = logging.getLogger(__name__)
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 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
async def create_access_token(
session: AsyncSession,
account: Account,
) -> str:
minutes = await resolve_session_timeout_minutes(session, account)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return jwt.encode(
{"sub": str(account.id), "email": account.email, "exp": expires_at},
settings.jwt_secret,
algorithm="HS256",
)
async def build_auth_response(
session: AsyncSession,
account: Account,
) -> AuthResponse:
return AuthResponse(
access_token=await create_access_token(session, account),
account=await account_read(session, account),
)
async def build_security_read(
session: AsyncSession,
account: Account,
) -> AccountSecurityRead:
mfa_available = False
try:
parse_india_phone(account.phone_number)
mfa_available = True
except ValueError:
mfa_available = False
org_timeout = None
if account.company_domain:
from app.services.org_security import get_or_create_security_settings
org = await get_or_create_security_settings(session, account.company_domain)
org_timeout = org.session_timeout_minutes
effective = await resolve_session_timeout_minutes(session, account)
return AccountSecurityRead(
mfa_enabled=account.mfa_enabled,
mfa_available=mfa_available,
session_timeout_minutes=account.session_timeout_minutes,
effective_session_timeout_minutes=effective,
org_session_timeout_minutes=org_timeout,
)
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("/signup/otp/send", response_model=SignupOtpSendResponse)
async def send_signup_phone_otp(
payload: SignupOtpSendRequest,
database=Depends(get_mongo_database),
) -> SignupOtpSendResponse:
result = await send_signup_otp(
database,
payload.country_code.strip(),
payload.phone.strip(),
)
return SignupOtpSendResponse(**result)
@router.post(
"/register",
response_model=AuthResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(
payload: AccountRegister,
session: AsyncSession = Depends(get_postgres_session),
database=Depends(get_mongo_database),
) -> 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)
country_code = payload.country_code.strip()
phone = payload.phone.strip()
if country_code == INDIA_COUNTRY_CODE:
if not payload.otp:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Verification code is required for Indian phone numbers.",
)
await verify_signup_otp(database, country_code, phone, payload.otp)
phone_number = format_phone_e164(country_code, phone)
existing = await session.scalar(select(Account).where(Account.email == email))
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That email address is already registered.",
)
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 await build_auth_response(session, account)
@router.post("/login", response_model=AuthResponse | LoginChallengeResponse)
async def login(
payload: LoginRequest,
session: AsyncSession = Depends(get_postgres_session),
database=Depends(get_mongo_database),
) -> AuthResponse | LoginChallengeResponse:
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.",
)
if not await account_requires_mfa(session, account):
return await build_auth_response(session, account)
if payload.challenge_token and payload.otp:
await verify_login_challenge(
database,
payload.challenge_token,
account,
payload.otp,
)
return await build_auth_response(session, account)
if payload.challenge_token or payload.otp:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Enter the verification code sent to your phone.",
)
try:
parse_india_phone(account.phone_number)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Multi-factor authentication is required but your account does not have a supported phone number.",
) from error
challenge_token, hint = await create_login_challenge(database, account)
return LoginChallengeResponse(
challenge_token=challenge_token,
phone_hint=hint,
expires_in_minutes=CHALLENGE_TTL_MINUTES,
)
@router.post("/login/otp/resend", response_model=LoginOtpResendResponse)
async def resend_login_otp(
payload: LoginOtpResendRequest,
database=Depends(get_mongo_database),
) -> LoginOtpResendResponse:
hint = await resend_login_challenge_otp(database, payload.challenge_token)
return LoginOtpResendResponse(
phone_hint=hint,
expires_in_minutes=CHALLENGE_TTL_MINUTES,
)
@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)
try:
await send_welcome_email(session, account)
except Exception as error:
logger.warning("Welcome email could not be sent: %s", error)
return await build_auth_response(session, account)
@router.post("/reset-password", response_model=PasswordResetResponse)
async def reset_password(
payload: PasswordResetRequest,
session: AsyncSession = Depends(get_postgres_session),
) -> PasswordResetResponse:
validate_password(payload.new_password)
account = await session.scalar(
select(Account).where(Account.email == str(payload.email).lower())
)
password_hash.verify(
payload.new_password,
account.password_hash if account is not None else dummy_password_hash,
)
if account is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No account was found for that email.",
)
if account.member_status == "invited":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accept your invitation email to set your password before resetting it.",
)
account.password_hash = password_hash.hash(payload.new_password)
account.must_change_password = False
await session.commit()
return PasswordResetResponse()
@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)
def validate_profile_picture(value: str | None) -> str | None:
if value is None:
return None
trimmed = value.strip()
if not trimmed:
return None
if not trimmed.startswith("data:image/"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Profile picture must be a JPG, PNG, or WebP image.",
)
if len(trimmed) > 700_000:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Profile picture must be 512 KB or smaller.",
)
return trimmed
@router.patch("/me", response_model=AccountRead)
async def update_me(
payload: AccountProfileUpdate,
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> AccountRead:
if "profile_picture_url" in payload.model_fields_set:
account.profile_picture_url = validate_profile_picture(payload.profile_picture_url)
await session.commit()
await session.refresh(account)
return await account_read(session, account)
@router.get("/me/security", response_model=AccountSecurityRead)
async def get_my_security(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> AccountSecurityRead:
return await build_security_read(session, account)
@router.patch("/me/security", response_model=AccountSecurityRead)
async def update_my_security(
payload: AccountSecurityUpdate,
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> AccountSecurityRead:
updates = payload.model_dump(exclude_unset=True)
if "session_timeout_minutes" in updates:
validate_session_timeout_minutes(updates["session_timeout_minutes"])
account.session_timeout_minutes = updates["session_timeout_minutes"]
if "mfa_enabled" in updates:
enabled = updates["mfa_enabled"]
if enabled:
try:
parse_india_phone(account.phone_number)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
account.mfa_enabled = enabled
await session.commit()
await session.refresh(account)
return await build_security_read(session, account)