543 lines
18 KiB
Python
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)
|