Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import base64
import binascii

from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 8 additions & 2 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
16 changes: 15 additions & 1 deletion app/service/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading