setting, session and profile

This commit is contained in:
debu-2 2026-10-07 03:19:09 +05:30
parent 48eea5d201
commit 4a563f4f34
31 changed files with 1797 additions and 159 deletions

View 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

View file

@ -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",

View file

@ -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()

View 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

View 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}},
)