setting, session and profile
This commit is contained in:
parent
48eea5d201
commit
4a563f4f34
31 changed files with 1797 additions and 159 deletions
|
|
@ -19,7 +19,7 @@ class Settings(BaseSettings):
|
|||
)
|
||||
mongo_database: str = "agmail_messages"
|
||||
jwt_secret: str = "replace-this-development-secret"
|
||||
access_token_minutes: int = 60
|
||||
access_token_minutes: int = 10080
|
||||
|
||||
platform_domain: str = "gentic.email"
|
||||
platform_spf_include: str = "_spf.gentic.email"
|
||||
|
|
@ -55,6 +55,11 @@ class Settings(BaseSettings):
|
|||
dns_setup_username: str = "smtp-credentia"
|
||||
dns_setup_password: str = "Nishtha#01"
|
||||
|
||||
urmi_otp_api_url: str = "https://urmiai.com/api/v1/otp/generate"
|
||||
urmi_otp_api_key: str = ""
|
||||
urmi_otp_project_name: str = "gentic-email-login"
|
||||
urmi_otp_template_type: str = "login_authentication"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
|
|
|||
|
|
@ -151,6 +151,30 @@ async def migrate_user_org_columns(connection) -> None:
|
|||
"REFERENCES org_sections(id)"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_picture_url TEXT")
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN "
|
||||
"NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS session_timeout_minutes "
|
||||
"INTEGER NOT NULL DEFAULT 10080"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE org_security_settings "
|
||||
"ALTER COLUMN session_timeout_minutes SET DEFAULT 10080"
|
||||
)
|
||||
)
|
||||
await connection.execute(text("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_phone_number_key"))
|
||||
await connection.execute(text("DROP INDEX IF EXISTS users_phone_number_key"))
|
||||
await connection.execute(text("DROP INDEX IF EXISTS ix_users_phone_number"))
|
||||
|
||||
|
||||
async def migrate_legacy_user_tables(connection) -> None:
|
||||
|
|
@ -342,6 +366,31 @@ async def lifespan(_: FastAPI):
|
|||
mongo_db.mailbox_entries,
|
||||
[("account_id", 1), ("message_scope", 1), ("mailbox", 1), ("event_at", -1)],
|
||||
)
|
||||
await ensure_mongo_index(
|
||||
mongo_db.signup_otps,
|
||||
[("phone_key", 1), ("consumed", 1), ("created_at", -1)],
|
||||
)
|
||||
await ensure_mongo_index(
|
||||
mongo_db.signup_otps,
|
||||
[("expires_at", 1)],
|
||||
expireAfterSeconds=0,
|
||||
name="signup_otps_expires_at_ttl",
|
||||
)
|
||||
await ensure_mongo_index(
|
||||
mongo_db.signup_otps,
|
||||
[("phone_key", 1), ("purpose", 1), ("purpose_key", 1), ("consumed", 1), ("created_at", -1)],
|
||||
)
|
||||
await ensure_mongo_index(
|
||||
mongo_db.login_mfa_challenges,
|
||||
[("challenge_token", 1)],
|
||||
unique=True,
|
||||
)
|
||||
await ensure_mongo_index(
|
||||
mongo_db.login_mfa_challenges,
|
||||
[("expires_at", 1)],
|
||||
expireAfterSeconds=0,
|
||||
name="login_mfa_challenges_expires_at_ttl",
|
||||
)
|
||||
await ensure_mongo_index(
|
||||
mongo_db.messages,
|
||||
[("message_id", 1), ("message_scope", 1)],
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ class Account(Base):
|
|||
first_name: Mapped[str] = mapped_column(String(80))
|
||||
last_name: Mapped[str] = mapped_column(String(80))
|
||||
date_of_birth: Mapped[date] = mapped_column(Date)
|
||||
phone_number: Mapped[str] = mapped_column(String(24), unique=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(24))
|
||||
profile_picture_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True)
|
||||
org_role: Mapped[str] = mapped_column(String(20), default="user")
|
||||
member_status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
|
|
@ -50,6 +51,8 @@ class Account(Base):
|
|||
)
|
||||
invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=10080)
|
||||
section_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("org_sections.id"),
|
||||
|
|
@ -198,7 +201,7 @@ class OrgSecuritySettings(Base):
|
|||
require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
require_mfa_admins: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
require_mfa_all_users: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=60)
|
||||
session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=10080)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -16,14 +16,22 @@ class AccountRegister(BaseModel):
|
|||
first_name: str = Field(min_length=1, max_length=80)
|
||||
last_name: str = Field(min_length=1, max_length=80)
|
||||
date_of_birth: date
|
||||
phone_number: str = Field(
|
||||
min_length=7,
|
||||
max_length=24,
|
||||
pattern=r"^\+?[0-9][0-9 ()-]{5,22}[0-9]$",
|
||||
)
|
||||
country_code: str = Field(min_length=1, max_length=4, pattern=r"^\d+$")
|
||||
phone: str = Field(min_length=6, max_length=15, pattern=r"^\d+$")
|
||||
otp: str | None = Field(default=None, min_length=4, max_length=8)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class SignupOtpSendRequest(BaseModel):
|
||||
country_code: str = Field(min_length=1, max_length=4, pattern=r"^\d+$")
|
||||
phone: str = Field(min_length=6, max_length=15, pattern=r"^\d+$")
|
||||
|
||||
|
||||
class SignupOtpSendResponse(BaseModel):
|
||||
message: str
|
||||
expires_in_minutes: int
|
||||
|
||||
|
||||
class AccountRead(BaseModel):
|
||||
id: UUID
|
||||
account_type: str
|
||||
|
|
@ -32,6 +40,7 @@ class AccountRead(BaseModel):
|
|||
last_name: str
|
||||
date_of_birth: date
|
||||
phone_number: str
|
||||
profile_picture_url: str | None = None
|
||||
company_domain: str | None
|
||||
is_domain_owner: bool = False
|
||||
must_change_password: bool = False
|
||||
|
|
@ -40,9 +49,15 @@ class AccountRead(BaseModel):
|
|||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AccountProfileUpdate(BaseModel):
|
||||
profile_picture_url: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
otp: str | None = Field(default=None, min_length=4, max_length=8)
|
||||
challenge_token: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
|
|
@ -51,6 +66,35 @@ class AuthResponse(BaseModel):
|
|||
account: AccountRead
|
||||
|
||||
|
||||
class LoginChallengeResponse(BaseModel):
|
||||
mfa_required: Literal[True] = True
|
||||
challenge_token: str
|
||||
phone_hint: str
|
||||
expires_in_minutes: int
|
||||
|
||||
|
||||
class LoginOtpResendRequest(BaseModel):
|
||||
challenge_token: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginOtpResendResponse(BaseModel):
|
||||
phone_hint: str
|
||||
expires_in_minutes: int
|
||||
|
||||
|
||||
class AccountSecurityRead(BaseModel):
|
||||
mfa_enabled: bool
|
||||
mfa_available: bool
|
||||
session_timeout_minutes: int
|
||||
effective_session_timeout_minutes: int
|
||||
org_session_timeout_minutes: int | None = None
|
||||
|
||||
|
||||
class AccountSecurityUpdate(BaseModel):
|
||||
mfa_enabled: bool | None = None
|
||||
session_timeout_minutes: int | None = None
|
||||
|
||||
|
||||
class OrgMemberCreate(BaseModel):
|
||||
first_name: str = Field(min_length=1, max_length=80)
|
||||
last_name: str = Field(min_length=1, max_length=80)
|
||||
|
|
@ -367,7 +411,7 @@ class OrgSecuritySettingsUpdate(BaseModel):
|
|||
require_strong_passwords: bool | None = None
|
||||
require_mfa_admins: bool | None = None
|
||||
require_mfa_all_users: bool | None = None
|
||||
session_timeout_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
session_timeout_minutes: int | None = Field(default=None, ge=60, le=129600)
|
||||
|
||||
|
||||
class OrgMemberRoleUpdate(BaseModel):
|
||||
|
|
|
|||
173
backend/app/services/login_mfa.py
Normal file
173
backend/app/services/login_mfa.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Login-time MFA challenges and OTP verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Account
|
||||
from app.services.org_security import get_or_create_security_settings
|
||||
from app.services.urmi_otp import (
|
||||
INDIA_COUNTRY_CODE,
|
||||
OTP_TTL_MINUTES,
|
||||
_hash_otp,
|
||||
parse_india_phone,
|
||||
send_phone_otp,
|
||||
)
|
||||
|
||||
CHALLENGE_TTL_MINUTES = 10
|
||||
MAX_VERIFY_ATTEMPTS = 5
|
||||
|
||||
|
||||
def phone_hint(phone_number: str) -> str:
|
||||
digits = re.sub(r"\D", "", phone_number)
|
||||
if len(digits) >= 4:
|
||||
return f"•••• {digits[-4:]}"
|
||||
return "your phone"
|
||||
|
||||
|
||||
async def account_requires_mfa(session: AsyncSession, account: Account) -> bool:
|
||||
if account.mfa_enabled:
|
||||
return True
|
||||
if not account.company_domain:
|
||||
return False
|
||||
org = await get_or_create_security_settings(session, account.company_domain)
|
||||
if org.require_mfa_all_users:
|
||||
return True
|
||||
if org.require_mfa_admins and account.org_role in {"admin", "owner"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mfa_phone_parts(account: Account) -> tuple[str, str]:
|
||||
try:
|
||||
return parse_india_phone(account.phone_number)
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
def assert_mfa_phone(account: Account) -> None:
|
||||
try:
|
||||
parse_india_phone(account.phone_number)
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
async def create_login_challenge(
|
||||
database,
|
||||
account: Account,
|
||||
) -> tuple[str, str]:
|
||||
country_code, local_phone = mfa_phone_parts(account)
|
||||
challenge_token = secrets.token_urlsafe(32)
|
||||
otp, _ = await send_phone_otp(
|
||||
database,
|
||||
country_code,
|
||||
local_phone,
|
||||
purpose="login_mfa",
|
||||
purpose_key=challenge_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
await database.login_mfa_challenges.update_many(
|
||||
{"account_id": str(account.id), "consumed": False},
|
||||
{"$set": {"consumed": True}},
|
||||
)
|
||||
await database.login_mfa_challenges.insert_one(
|
||||
{
|
||||
"challenge_token": challenge_token,
|
||||
"account_id": str(account.id),
|
||||
"phone_key": f"{country_code}{local_phone}",
|
||||
"otp_hash": _hash_otp(otp),
|
||||
"attempts": 0,
|
||||
"consumed": False,
|
||||
"sent_at": now,
|
||||
"created_at": now,
|
||||
"expires_at": now + timedelta(minutes=CHALLENGE_TTL_MINUTES),
|
||||
}
|
||||
)
|
||||
return challenge_token, phone_hint(account.phone_number)
|
||||
|
||||
|
||||
async def resend_login_challenge_otp(database, challenge_token: str) -> str:
|
||||
record = await _active_challenge(database, challenge_token)
|
||||
country_code, local_phone = record["phone_key"][:2], record["phone_key"][2:]
|
||||
otp, _ = await send_phone_otp(
|
||||
database,
|
||||
country_code,
|
||||
local_phone,
|
||||
purpose="login_mfa",
|
||||
purpose_key=challenge_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
await database.login_mfa_challenges.update_one(
|
||||
{"_id": record["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"otp_hash": _hash_otp(otp),
|
||||
"attempts": 0,
|
||||
"sent_at": now,
|
||||
"expires_at": now + timedelta(minutes=CHALLENGE_TTL_MINUTES),
|
||||
}
|
||||
},
|
||||
)
|
||||
return phone_hint(f"+{record['phone_key']}")
|
||||
|
||||
|
||||
async def verify_login_challenge(
|
||||
database,
|
||||
challenge_token: str,
|
||||
account: Account,
|
||||
otp: str,
|
||||
) -> None:
|
||||
record = await _active_challenge(database, challenge_token)
|
||||
if record["account_id"] != str(account.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Verification session is invalid. Sign in again.",
|
||||
)
|
||||
code = otp.strip()
|
||||
if not re.fullmatch(r"\d{6}", code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Enter the 6-digit verification code.",
|
||||
)
|
||||
if record.get("attempts", 0) >= MAX_VERIFY_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Too many incorrect attempts. Sign in again to receive a new code.",
|
||||
)
|
||||
if _hash_otp(code) != record["otp_hash"]:
|
||||
await database.login_mfa_challenges.update_one(
|
||||
{"_id": record["_id"]},
|
||||
{"$inc": {"attempts": 1}},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect verification code.",
|
||||
)
|
||||
await database.login_mfa_challenges.update_one(
|
||||
{"_id": record["_id"]},
|
||||
{"$set": {"consumed": True, "verified_at": datetime.now(timezone.utc)}},
|
||||
)
|
||||
|
||||
|
||||
async def _active_challenge(database, challenge_token: str) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
record = await database.login_mfa_challenges.find_one(
|
||||
{"challenge_token": challenge_token, "consumed": False},
|
||||
)
|
||||
if record is None or record.get("expires_at") <= now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verification session expired. Sign in again.",
|
||||
)
|
||||
return record
|
||||
|
|
@ -138,10 +138,6 @@ async def create_org_member(
|
|||
raise ValueError("Selected section was not found.")
|
||||
|
||||
phone_number = _placeholder_phone()
|
||||
while await session.scalar(
|
||||
select(Account).where(Account.phone_number == phone_number)
|
||||
):
|
||||
phone_number = _placeholder_phone()
|
||||
|
||||
member = Account(
|
||||
account_type="company",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.models import OrgSecuritySettings
|
||||
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
|
||||
|
||||
ALLOWED_SESSION_TIMEOUTS = {15, 30, 60, 120, 480, 1440}
|
||||
from app.services.session_policy import validate_session_timeout_minutes
|
||||
|
||||
|
||||
async def get_or_create_security_settings(
|
||||
|
|
@ -43,12 +42,7 @@ async def update_security_settings(
|
|||
settings = await get_or_create_security_settings(session, company_domain)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if "session_timeout_minutes" in updates:
|
||||
timeout = updates["session_timeout_minutes"]
|
||||
if timeout not in ALLOWED_SESSION_TIMEOUTS:
|
||||
raise ValueError(
|
||||
"Session timeout must be one of: "
|
||||
+ ", ".join(str(value) for value in sorted(ALLOWED_SESSION_TIMEOUTS))
|
||||
)
|
||||
validate_session_timeout_minutes(updates["session_timeout_minutes"])
|
||||
for field, value in updates.items():
|
||||
setattr(settings, field, value)
|
||||
await session.flush()
|
||||
|
|
|
|||
47
backend/app/services/session_policy.py
Normal file
47
backend/app/services/session_policy.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Session duration policy for accounts and organizations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Account
|
||||
from app.services.org_security import get_or_create_security_settings
|
||||
|
||||
DEFAULT_SESSION_TIMEOUT_MINUTES = 10_080 # 1 week
|
||||
|
||||
ALLOWED_SESSION_TIMEOUTS = (
|
||||
60, # 1 hour
|
||||
120, # 2 hours
|
||||
240, # 4 hours
|
||||
480, # 8 hours
|
||||
720, # 12 hours
|
||||
1_440, # 1 day
|
||||
2_880, # 2 days
|
||||
4_320, # 3 days
|
||||
7_200, # 5 days
|
||||
10_080, # 1 week
|
||||
20_160, # 2 weeks
|
||||
43_200, # 30 days
|
||||
64_800, # 45 days
|
||||
86_400, # 60 days
|
||||
129_600, # 90 days
|
||||
)
|
||||
|
||||
ALLOWED_SESSION_TIMEOUT_SET = set(ALLOWED_SESSION_TIMEOUTS)
|
||||
|
||||
|
||||
def validate_session_timeout_minutes(minutes: int) -> None:
|
||||
if minutes not in ALLOWED_SESSION_TIMEOUT_SET:
|
||||
allowed = ", ".join(str(value) for value in ALLOWED_SESSION_TIMEOUTS)
|
||||
raise ValueError(f"Session timeout must be one of: {allowed}")
|
||||
|
||||
|
||||
async def resolve_session_timeout_minutes(
|
||||
session: AsyncSession,
|
||||
account: Account,
|
||||
) -> int:
|
||||
timeout = account.session_timeout_minutes or DEFAULT_SESSION_TIMEOUT_MINUTES
|
||||
if account.company_domain:
|
||||
org = await get_or_create_security_settings(session, account.company_domain)
|
||||
timeout = min(timeout, org.session_timeout_minutes)
|
||||
return timeout
|
||||
236
backend/app/services/urmi_otp.py
Normal file
236
backend/app/services/urmi_otp.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
INDIA_COUNTRY_CODE = "91"
|
||||
OTP_LENGTH = 6
|
||||
OTP_TTL_MINUTES = 10
|
||||
OTP_RESEND_SECONDS = 60
|
||||
MAX_VERIFY_ATTEMPTS = 5
|
||||
|
||||
INDIAN_MOBILE_RE = re.compile(r"^[6-9]\d{9}$")
|
||||
|
||||
|
||||
def phone_key(country_code: str, phone: str) -> str:
|
||||
return f"{country_code.strip()}{phone.strip()}"
|
||||
|
||||
|
||||
def format_phone_e164(country_code: str, phone: str) -> str:
|
||||
return f"+{phone_key(country_code, phone)}"
|
||||
|
||||
|
||||
def parse_india_phone(phone_number: str) -> tuple[str, str]:
|
||||
digits = re.sub(r"\D", "", phone_number or "")
|
||||
if digits.startswith("91") and len(digits) == 12:
|
||||
local = digits[2:]
|
||||
elif len(digits) == 10:
|
||||
local = digits
|
||||
else:
|
||||
raise ValueError(
|
||||
"Multi-factor authentication requires an Indian (+91) mobile number on your account."
|
||||
)
|
||||
if not INDIAN_MOBILE_RE.fullmatch(local):
|
||||
raise ValueError(
|
||||
"Multi-factor authentication requires a valid Indian mobile number on your account."
|
||||
)
|
||||
return INDIA_COUNTRY_CODE, local
|
||||
|
||||
|
||||
def _hash_otp(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _generate_otp() -> str:
|
||||
return f"{secrets.randbelow(10 ** OTP_LENGTH):0{OTP_LENGTH}d}"
|
||||
|
||||
|
||||
async def send_phone_otp(
|
||||
database,
|
||||
country_code: str,
|
||||
phone: str,
|
||||
*,
|
||||
purpose: str,
|
||||
purpose_key: str,
|
||||
) -> tuple[str, dict]:
|
||||
if country_code != INDIA_COUNTRY_CODE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone verification is only available for India (+91).",
|
||||
)
|
||||
local = phone.strip()
|
||||
if not INDIAN_MOBILE_RE.fullmatch(local):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Enter a valid 10-digit Indian mobile number.",
|
||||
)
|
||||
if not settings.urmi_otp_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Phone verification is temporarily unavailable.",
|
||||
)
|
||||
|
||||
key = phone_key(country_code, local)
|
||||
now = datetime.now(timezone.utc)
|
||||
existing = await database.signup_otps.find_one(
|
||||
{
|
||||
"phone_key": key,
|
||||
"purpose": purpose,
|
||||
"purpose_key": purpose_key,
|
||||
"consumed": False,
|
||||
},
|
||||
sort=[("created_at", -1)],
|
||||
)
|
||||
if existing and existing.get("sent_at"):
|
||||
retry_at = existing["sent_at"] + timedelta(seconds=OTP_RESEND_SECONDS)
|
||||
if retry_at > now:
|
||||
wait = int((retry_at - now).total_seconds())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Wait {wait}s before requesting another code.",
|
||||
)
|
||||
|
||||
otp = _generate_otp()
|
||||
payload = {
|
||||
"project_name": settings.urmi_otp_project_name,
|
||||
"template_type": settings.urmi_otp_template_type,
|
||||
"to": key,
|
||||
"variables": {
|
||||
"otp": otp,
|
||||
"validity_minutes": str(OTP_TTL_MINUTES),
|
||||
},
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Api-Key": settings.urmi_otp_api_key,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(
|
||||
settings.urmi_otp_api_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.HTTPError as error:
|
||||
logger.exception("Urmi OTP request failed for %s", key)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Could not send verification code. Try again shortly.",
|
||||
) from error
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.warning(
|
||||
"Urmi OTP rejected request for %s: %s %s",
|
||||
key,
|
||||
response.status_code,
|
||||
response.text[:300],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Could not send verification code. Try again shortly.",
|
||||
)
|
||||
|
||||
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
||||
await database.signup_otps.update_many(
|
||||
{
|
||||
"phone_key": key,
|
||||
"purpose": purpose,
|
||||
"purpose_key": purpose_key,
|
||||
"consumed": False,
|
||||
},
|
||||
{"$set": {"consumed": True}},
|
||||
)
|
||||
await database.signup_otps.insert_one(
|
||||
{
|
||||
"phone_key": key,
|
||||
"purpose": purpose,
|
||||
"purpose_key": purpose_key,
|
||||
"otp_hash": _hash_otp(otp),
|
||||
"attempts": 0,
|
||||
"consumed": False,
|
||||
"sent_at": now,
|
||||
"created_at": now,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
)
|
||||
return otp, {
|
||||
"message": "Verification code sent.",
|
||||
"expires_in_minutes": OTP_TTL_MINUTES,
|
||||
}
|
||||
|
||||
|
||||
async def send_signup_otp(database, country_code: str, phone: str) -> dict:
|
||||
key = phone_key(country_code, phone.strip())
|
||||
_, meta = await send_phone_otp(
|
||||
database,
|
||||
country_code,
|
||||
phone,
|
||||
purpose="signup",
|
||||
purpose_key=key,
|
||||
)
|
||||
return meta
|
||||
|
||||
|
||||
async def verify_signup_otp(database, country_code: str, phone: str, otp: str) -> None:
|
||||
if country_code != INDIA_COUNTRY_CODE:
|
||||
return
|
||||
|
||||
local = phone.strip()
|
||||
code = otp.strip()
|
||||
if not INDIAN_MOBILE_RE.fullmatch(local):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Enter a valid 10-digit Indian mobile number.",
|
||||
)
|
||||
if not re.fullmatch(rf"\d{{{OTP_LENGTH}}}", code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Enter the 6-digit verification code.",
|
||||
)
|
||||
|
||||
key = phone_key(country_code, local)
|
||||
now = datetime.now(timezone.utc)
|
||||
record = await database.signup_otps.find_one(
|
||||
{
|
||||
"phone_key": key,
|
||||
"purpose": "signup",
|
||||
"purpose_key": key,
|
||||
"consumed": False,
|
||||
},
|
||||
sort=[("created_at", -1)],
|
||||
)
|
||||
if record is None or record.get("expires_at") <= now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verification code expired. Request a new one.",
|
||||
)
|
||||
if record.get("attempts", 0) >= MAX_VERIFY_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Too many incorrect attempts. Request a new code.",
|
||||
)
|
||||
|
||||
if _hash_otp(code) != record["otp_hash"]:
|
||||
await database.signup_otps.update_one(
|
||||
{"_id": record["_id"]},
|
||||
{"$inc": {"attempts": 1}},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect verification code.",
|
||||
)
|
||||
|
||||
await database.signup_otps.update_one(
|
||||
{"_id": record["_id"]},
|
||||
{"$set": {"consumed": True, "verified_at": now}},
|
||||
)
|
||||
|
|
@ -15,5 +15,6 @@ aiosmtpd>=1.4.6
|
|||
google-cloud-storage>=3.0
|
||||
google-crc32c>=1.7
|
||||
dnspython>=2.7
|
||||
httpx>=0.28
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.25
|
||||
|
|
|
|||
21
backend/tests/test_session_policy.py
Normal file
21
backend/tests/test_session_policy.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import pytest
|
||||
|
||||
from app.services.session_policy import (
|
||||
ALLOWED_SESSION_TIMEOUT_SET,
|
||||
DEFAULT_SESSION_TIMEOUT_MINUTES,
|
||||
validate_session_timeout_minutes,
|
||||
)
|
||||
|
||||
|
||||
def test_default_session_is_one_week():
|
||||
assert DEFAULT_SESSION_TIMEOUT_MINUTES == 10_080
|
||||
|
||||
|
||||
def test_allowed_session_timeouts_cover_one_hour_to_ninety_days():
|
||||
assert min(ALLOWED_SESSION_TIMEOUT_SET) == 60
|
||||
assert max(ALLOWED_SESSION_TIMEOUT_SET) == 129_600
|
||||
|
||||
|
||||
def test_validate_session_timeout_rejects_unknown_values():
|
||||
with pytest.raises(ValueError):
|
||||
validate_session_timeout_minutes(90)
|
||||
Loading…
Add table
Add a link
Reference in a new issue