setting, session and profile
This commit is contained in:
parent
48eea5d201
commit
4a563f4f34
31 changed files with 1797 additions and 159 deletions
|
|
@ -8,25 +8,52 @@ 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 import 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.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__)
|
||||
|
|
@ -49,11 +76,6 @@ domain_pattern = re.compile(
|
|||
)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -98,10 +120,12 @@ def build_email(payload: AccountRegister) -> tuple[str, str | None]:
|
|||
) from error
|
||||
|
||||
|
||||
def create_access_token(account: Account) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.access_token_minutes
|
||||
)
|
||||
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,
|
||||
|
|
@ -109,6 +133,42 @@ def create_access_token(account: Account) -> str:
|
|||
)
|
||||
|
||||
|
||||
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,
|
||||
|
|
@ -152,6 +212,19 @@ async def get_current_account(
|
|||
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,
|
||||
|
|
@ -160,6 +233,7 @@ async def get_current_account(
|
|||
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(
|
||||
|
|
@ -168,20 +242,23 @@ async def register(
|
|||
)
|
||||
validate_password(payload.password)
|
||||
email, company_domain = build_email(payload)
|
||||
phone_number = normalize_phone(payload.phone_number)
|
||||
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(
|
||||
or_(Account.email == email, Account.phone_number == phone_number)
|
||||
)
|
||||
)
|
||||
existing = await session.scalar(select(Account).where(Account.email == email))
|
||||
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="That email address is already registered.",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
|
||||
account = Account(
|
||||
account_type=payload.account_type,
|
||||
|
|
@ -215,17 +292,15 @@ async def register(
|
|||
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),
|
||||
)
|
||||
return await build_auth_response(session, account)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
@router.post("/login", response_model=AuthResponse | LoginChallengeResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> AuthResponse:
|
||||
database=Depends(get_mongo_database),
|
||||
) -> AuthResponse | LoginChallengeResponse:
|
||||
account = await session.scalar(
|
||||
select(Account).where(Account.email == str(payload.email).lower())
|
||||
)
|
||||
|
|
@ -243,9 +318,49 @@ async def login(
|
|||
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),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -302,10 +417,7 @@ async def accept_invite(
|
|||
await send_welcome_email(session, account)
|
||||
except Exception as error:
|
||||
logger.warning("Welcome email could not be sent: %s", error)
|
||||
return AuthResponse(
|
||||
access_token=create_access_token(account),
|
||||
account=await account_read(session, account),
|
||||
)
|
||||
return await build_auth_response(session, account)
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=PasswordResetResponse)
|
||||
|
|
@ -363,3 +475,69 @@ async def get_me(
|
|||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue