Files
MathApp/accounts/authentication.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

109 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.conf import settings
from django.core import signing
from django.core.cache import cache
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from .models import User
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
# فیلدهای غیرِ‌رابطه‌ایِ User که کش می‌شن؛ همه‌ی concrete fieldها تا بازسازیِ
# instance برای save(update_fields=...) امن بمونه.
_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields]
def _auth_user_cache_key(user_id: int) -> str:
return f'auth:user:{user_id}'
def invalidate_user_cache(user_id: int) -> None:
cache.delete(_auth_user_cache_key(user_id))
def _load_user_or_fail(user_id: int) -> User:
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
def _get_request_user(user_id: int) -> User:
"""
ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده
می‌شه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازی‌شده
کاملِ همه‌ی فیلدهاست، پس save(update_fields=[...]) روش امنه.
"""
ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0)
if not ttl:
return _load_user_or_fail(user_id)
key = _auth_user_cache_key(user_id)
data = cache.get(key)
if data is None:
user = _load_user_or_fail(user_id)
cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl)
return user
user = User(**data)
user._state.adding = False
user._state.db = 'default'
return user
def issue_access_token(user_id: int) -> str:
"""
ساخت access token امضاشده (stateless) — دیتابیس ذخیره نمی‌شه، فقط
امضا و expiry داره. با django.core.signing (بدون نیاز به کتابخونه‌ی JWT).
"""
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
return signer.sign(str(user_id))
def decode_access_token(token: str) -> int:
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
try:
value = signer.unsign(token, max_age=settings.ACCESS_TOKEN_TTL_SECONDS)
except signing.SignatureExpired:
raise AuthenticationFailed('توکن منقضی شده است.')
except signing.BadSignature:
raise AuthenticationFailed('توکن نامعتبر است.')
return int(value)
def _issued_at(token: str) -> int:
"""
ثانیه‌ی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
base62؛ فقط بعد از unsignِ موفق صدا زده می‌شود، پس ساختار معتبر است.
"""
return signing.b62_decode(token.rsplit(':', 2)[-2])
class TadrisinoTokenAuthentication(BaseAuthentication):
"""
هدر مورد انتظار: Authorization: Bearer <access_token>
اگه هدر نباشه، درخواست به‌صورت anonymous ادامه پیدا می‌کنه (None, None)
و View خودش تصمیم می‌گیره که آیا auth اجباریه (IsAuthenticated) یا نه.
"""
keyword = 'Bearer'
def authenticate(self, request):
auth_header = request.headers.get('Authorization', '')
if not auth_header:
return None
parts = auth_header.split()
if len(parts) != 2 or parts[0] != self.keyword:
return None
token = parts[1]
user_id = decode_access_token(token)
user = _get_request_user(user_id)
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
raise AuthenticationFailed('از حساب خارج شده‌اید؛ دوباره وارد شوید.')
return (user, token)
def authenticate_header(self, request):
return self.keyword