Implement phone number normalization and access control for Telegram users

- Added functionality to normalize phone numbers for comparison, ensuring only digits are stored and checked.
- Updated configuration to include optional phone number allowlists for users and admins in the environment settings.
- Enhanced authentication logic to allow access based on normalized phone numbers, in addition to usernames.
- Introduced new helper functions for parsing and validating phone numbers, improving code organization and maintainability.
- Added unit tests to validate phone normalization and access control based on phone numbers.
This commit is contained in:
2026-02-18 16:11:44 +03:00
parent d0d22c150a
commit 59ba2a9ca4
10 changed files with 344 additions and 48 deletions

View File

@@ -4,12 +4,12 @@ import logging
import re
from typing import Annotated, Generator
from fastapi import Header, HTTPException, Query, Request
from fastapi import Depends, Header, HTTPException, Query, Request
from sqlalchemy.orm import Session
import duty_teller.config as config
from duty_teller.api.telegram_auth import validate_init_data_with_reason
from duty_teller.db.repository import get_duties
from duty_teller.db.repository import get_duties, get_user_by_telegram_id
from duty_teller.db.schemas import DutyWithUser
from duty_teller.db.session import session_scope
from duty_teller.i18n import t
@@ -74,8 +74,9 @@ def require_miniapp_username(
x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
session: Session = Depends(get_db_session),
) -> str:
return get_authenticated_username(request, x_telegram_init_data)
return get_authenticated_username(request, x_telegram_init_data, session)
def _is_private_client(client_host: str | None) -> bool:
@@ -95,31 +96,43 @@ def _is_private_client(client_host: str | None) -> bool:
def get_authenticated_username(
request: Request, x_telegram_init_data: str | None
request: Request,
x_telegram_init_data: str | None,
session: Session,
) -> str:
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth."""
if config.MINI_APP_SKIP_AUTH:
log.warning("allowing without any auth check (MINI_APP_SKIP_AUTH is set)")
return ""
init_data = (x_telegram_init_data or "").strip()
if not init_data:
client_host = request.client.host if request.client else None
if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH:
if config.MINI_APP_SKIP_AUTH:
log.warning("allowing without initData (MINI_APP_SKIP_AUTH is set)")
if _is_private_client(client_host):
return ""
log.warning("no X-Telegram-Init-Data header (client=%s)", client_host)
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
username, auth_reason, lang = validate_init_data_with_reason(
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
init_data, config.BOT_TOKEN, max_age_seconds=max_age
)
if username is None:
if auth_reason != "ok":
log.warning("initData validation failed: %s", auth_reason)
raise HTTPException(
status_code=403, detail=_auth_error_detail(auth_reason, lang)
)
if not config.can_access_miniapp(username):
log.warning("username not in allowlist: %s", username)
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
return username
if username and config.can_access_miniapp(username):
return username
if telegram_user_id is not None:
user = get_user_by_telegram_id(session, telegram_user_id)
if user and user.phone and config.can_access_miniapp_by_phone(user.phone):
return username or (user.full_name or "") or f"id:{telegram_user_id}"
log.warning(
"username/phone not in allowlist (username=%s, telegram_id=%s)",
username,
telegram_user_id,
)
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
def fetch_duties_response(