From 59fa19f2654021acb603c9c7e786714b78e2fc85 Mon Sep 17 00:00:00 2001 From: wailbentafat Date: Mon, 7 Sep 2026 16:01:13 +0100 Subject: [PATCH] feat(auth): add OTP_ACTIVATED toggle and validate encryption_key at startup Add OTP_ACTIVATED setting (default false) so mobile registration can skip the OTP verification step and authenticate immediately, matching pre-OTP-feature behavior. /register now returns MobileAuthResponse directly when the flag is off, or the existing pending/OTP response when it's on. Also add a field_validator on encryption_key that checks it decodes to a valid 128/192/256-bit AES key. An invalid key previously passed startup silently and only surfaced as a 500 on /auth/refresh; now the app refuses to start with a clear error instead. Co-Authored-By: Claude Sonnet 5 --- app/core/config.py | 24 ++++++++++++++++++++++++ app/router/mobile/auth.py | 10 ++++++++-- app/service/users.py | 16 +++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 1d84e51..f77be71 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,3 +1,6 @@ +import base64 +import binascii + from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import field_validator @@ -76,6 +79,9 @@ class Settings(BaseSettings): # In dev env, registration OTPs are fixed to this value and the email/NATS # send is skipped, so mobile devs can verify without a real inbox. DEV_OTP_BYPASS_CODE: str = "000000" + # When false, mobile registration skips the OTP verification step entirely + # and creates the user + session directly (pre-OTP-feature behavior). + OTP_ACTIVATED: bool = False TRUST_PROXY_HEADERS: bool = True # Admin list defaults ADMIN_USERS_DEFAULT_LIMIT: int = 20 @@ -124,6 +130,24 @@ class Settings(BaseSettings): extra="ignore", ) + @field_validator("encryption_key") + @classmethod + def _validate_encryption_key(cls, value: str) -> str: + try: + key_bytes = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError( + "encryption_key must be base64-encoded (AESGCM key)" + ) from exc + if len(key_bytes) not in (16, 24, 32): + raise ValueError( + "encryption_key must decode to 128, 192, or 256 bits " + f"(got {len(key_bytes) * 8} bits); generate one with: " + "python -c \"import secrets, base64; " + "print(base64.b64encode(secrets.token_bytes(32)).decode())\"" + ) + return value + @field_validator("debug", mode="before") @classmethod def _parse_debug(cls, value): # type: ignore[no-untyped-def] diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 1c524ca..81efcdb 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -36,18 +36,24 @@ @router.post( "/register", - response_model=RegisterPendingResponse, + response_model=RegisterPendingResponse | MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))], ) async def mobile_register( req: MobileRegisterRequest, request: Request, container: Container = Depends(get_container), -) -> RegisterPendingResponse: +) -> RegisterPendingResponse | MobileAuthResponse: client_ip = get_client_ip(request) result = await container.auth_service.mobile_register( container.redis, req, client_ip=client_ip ) + if isinstance(result, MobileAuthResponse): + await container.audit_service.create_record( + event_type=AuditEventType.USER_SIGNUP, + user_id=result.user_id, + metadata={"endpoint": "register", "otp_activated": False}, + ) return result diff --git a/app/service/users.py b/app/service/users.py index 9c4ac45..83f7c8b 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -160,7 +160,7 @@ async def mobile_register( redis: RedisClient, req: MobileRegisterRequest, client_ip: Optional[str] = None, - ) -> RegisterPendingResponse: + ) -> RegisterPendingResponse | MobileAuthResponse: logger.info("mobile_register attempt") max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS @@ -186,6 +186,20 @@ async def mobile_register( hashed = hash_password(req.password) + if not settings.OTP_ACTIVATED: + logger.info("OTP deactivated, creating user directly") + user = await self.user_querier.create_user( + email=req.email, hashed_password=hashed + ) + if not user: + raise AppException.internal_error("Failed to create user") + return await self._create_mobile_session( + redis=redis, + user=user, + req=req, + is_new_user=True, + ) + pending_key = f"pending_user:{req.email}" pending_data = { "hashed_password": hashed,