Maqolalar 24 daqiqa 240 ko‘rish
Google Sign-In: iOS va Android id_token'ini backend'da to'g'ri verify qilish
Mobil ilova «Google bilan kirish»dan id_token oladi — backend uni verify qilmasdan ishonsa, autentifikatsiya teshik bo'ladi. Signature va audience tekshiruvi, ikki platformaning ikki client ID muammosi, public key'larni cache qilish, sub bo'yicha identity — to'liq kod bilan.
Mundarija
Metro ilovasida ro'yxatdan o'tish bitta tugma: "Google bilan kirish".
Foydalanuvchi uchun qulay, backend uchun esa nozik joy: mobil ilova bizga
Google'dan olgan id_tokenni yuboradi va biz shu satrga qarab odamga
kabinet ochamiz. Token'ni verify qilmasak — istalgan odam istalgan
email nomidan kiradi.
Oqim shunday:
Mobil ilova ──(Google SDK)──► Google ──► id_token (JWT)
│
└──POST /api/auth/google/ {"id_token": "..."}──► bizning backend
│
1) signature — Google'ning public key'i bilan
2) audience — token AYNAN BIZNING client ID uchunmi
3) iss / exp / iat — kim, qachongacha
4) sub bo'yicha identity: topish yoki yaratish
5) o'zimizning access/refresh token'ni qaytarish
Besh qadamning uchtasi kutubxona zimmasida, ikkitasi bizniki. Amalda eng ko'p o'tkazib yuboriladigani — ikkinchisi: signature to'g'ri chiqqani token aynan sizning ilovangizga atalganini anglatmaydi. Quyida shu qadam bitta argument tufayli qanday jimgina o'chib qolishini ko'rasiz.
Eng xavfli xato: token'ni verify emas, decode qilish
id_token — oddiy JWT, uni istalgan kutubxona signature'siz ham o'qib
beradi (jwt.decode(..., options={"verify_signature": False})). Ichidan
chiroyli email chiqadi — va tekshirmasdan ishongan backend'ga istalgan
qalbaki token o'tadi. decode — konvertni ochish, verify — muhrni
tekshirish. JWT tuzilishini eslash uchun:
JWT autentifikatsiya.
Nega o'z access token'imizni beramiz#
Google'ning id_tokeni — identityni isbotlaydigan hujjat, API'ga kirish
chiptasi emas: exp ~1 soat, uni yangilash Google SDK'ga bog'liq, ichida
bizning rollarimiz va ID'larimiz yo'q. Shuning uchun sxema ikki bosqichli:
id_tokenni bir marta verify qilamiz, keyin foydalanuvchiga o'zimizning
access/refresh juftligimizni beramiz — API hayoti oddiy JWT autentifikatsiya
bilan davom etadi. Mobil ilova ham yengil nafas oladi: har so'rovdan oldin
Google SDK'ni qo'zg'atmaydi.
1-qadam: ikki platforma, ikki client ID#
Google Cloud Console'da OAuth client'lar yaratiladi: Android, iOS va backend
uchun Web application turidagi client. Backend uchun muhim savol — token
ichidagi aud claim'ida ulardan qaysi biri turadi. Bu platformaga bog'liq:
-
Android — ilova Google'dan token so'raganda (
setServerClientId) Web client IDni beradi, token aynan shu ID uchun chiqariladi va Android client ID'siazpclaim'ida qoladi,audda emas; -
iOS —
audstandart holatda iOS client ID bo'ladi. IlovaGIDServerClientIDni Web client ID qilib sozlagan bo'lsa,audham Web client ID'ga aylanadi.
Shuning uchun backend bitta ID'ni emas, allowlistni biladi: odatda Web va iOS client ID'lari. Ro'yxat env'dan keladi, kodda qattiq yozilmaydi — dev, staging va production'da u har xil:
import os
from django.core.exceptions import ImproperlyConfigured
INSTALLED_APPS = [
# ...
"apps.accounts",
]
# vergul bilan: "123-web.apps.googleusercontent.com,123-ios.apps.googleusercontent.com"
GOOGLE_CLIENT_IDS = [
cid.strip() for cid in os.environ.get("GOOGLE_CLIENT_IDS", "").split(",") if cid.strip()
]
if not GOOGLE_CLIENT_IDS:
# bo'sh ro'yxat — hamma login yiqiladi. Buni deploy paytida bilgan
# yaxshi, birinchi foydalanuvchi shikoyat qilganda emas.
# CI va test muhitiga ham qiymat berib qo'ying (.env.example)
raise ImproperlyConfigured("GOOGLE_CLIENT_IDS bo'sh")
Bitta ID o'rniga ro'yxat yozish ortiqcha ish emas: u keyingi bo'limdagi eng ko'p uchraydigan production xatosining oldini oladi. Ro'yxat env'dan kelayotgani ham muhim — client ID dev, staging va production'da har xil, va uni almashtirish uchun yangi reliz kutib o'tirmaysiz.
«Android'da ishlayapti, iOS'da 401» sindromi
Eng ko'p uchraydigan production bug'i: sozlamada faqat Web client ID
turadi (Android shu bilan ishlaydi), iOS ilova esa GIDServerClientIDsiz
yig'ilgan — uning token'ida aud iOS client ID, verify yiqiladi.
Allowlist qilib qo'ying va ikkala platformadan kelgan token'ni sinab
ko'ring. audni bilishning eng tez yo'li: rad etilgan token'ni
jwt.ioda emas, log'dagi xato matnida ko'rish — kutubxona "Token has
wrong audience X, expected one of Y" deb aynan ikkala qiymatni yozadi.
2-qadam: verify funksiyasi#
Butun xavfsizlik shu faylda. Modul alohida turadi: view uni chaqiradi,
testlar uni mock qiladi, ertaga Apple Sign-In qo'shilsa — yonida apple.py
paydo bo'ladi.
Kod to'rtga bo'linadi: google.py token'ni verify qiladi, models.py
sub bilan User orasidagi bog'lanishni saqlaydi, services.py identity'dan
foydalanuvchi yasaydi, views.py esa HTTP qatlami bo'lib qoladi. Bu
bo'linish testlar uchun ham qulay: ertaga Apple Sign-In qo'shilsa, yonida
apple.py paydo bo'ladi, qolgan uchtasiga tegilmaydi.
Kerakli kutubxonalar uchta. cachecontrol ro'yxatda bejiz turmagan — nega
kerakligini kod ostida alohida tushuntiraman:
uv add google-auth cachecontrol djangorestframework-simplejwt
Kodda ikki narsaga diqqat qiling: audience argumenti va modul darajasidagi
session. Ikkalasi ham "ishlayapti-ku" deb e'tibordan chetda qoladigan, lekin
production'da qimmatga tushadigan joylar:
from dataclasses import dataclass
import cachecontrol
import requests
from django.conf import settings
from google.auth import exceptions as google_exceptions
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token as google_id_token
class GoogleAuthError(Exception):
"""id_token yaroqsiz — sababi client'ga ochilmaydi."""
class GoogleUnavailable(Exception):
"""Google'ning certs endpoint'iga yetib bo'lmadi — token aybdor emas."""
@dataclass(frozen=True)
class GoogleIdentity:
sub: str # Google hisobining O'ZGARMAS ID'si
email: str
name: str
picture: str
# Bitta session butun process uchun. cachecontrol Google javobidagi
# Cache-Control'ni o'qib, public key'larni xotirada saqlaydi
_cached_session = cachecontrol.CacheControl(requests.Session())
_google_request = google_requests.Request(session=_cached_session)
def verify_google_token(raw_token: str) -> GoogleIdentity:
try:
# verify_oauth2_token uch ishni birdan qiladi:
# 1) Google'ning public key'lari bilan SIGNATURE tekshiruvi
# 2) exp va iat tekshiruvi
# 3) iss accounts.google.com (yoki https://accounts.google.com) ekani
claims = google_id_token.verify_oauth2_token(
raw_token,
_google_request,
# audience list ham bo'ladi — kutubxonaning o'zi allowlist bilan
# solishtiradi. Bu yerga None yozilsa, `aud` UMUMAN tekshirilmaydi
audience=settings.GOOGLE_CLIENT_IDS,
# server soati Google'nikidan bir necha soniya farq qilsa,
# yangi token "Token used too early" bilan rad etilmasin
clock_skew_in_seconds=10,
)
except google_exceptions.TransportError as exc:
# Google javob bermadi. Bu "token yaroqsiz" degani EMAS —
# shuning uchun alohida xato (pastda 503 bo'lib qaytadi)
raise GoogleUnavailable(str(exc)) from exc
except (ValueError, google_exceptions.GoogleAuthError) as exc:
# signature, exp/iat va aud xatolari ValueError bo'lib keladi
# (InvalidValue, MalformedError — ikkalasi ham ValueError avlodi).
# Wrong issuer esa sof GoogleAuthError: uni ushlamasak,
# endpoint 401 o'rniga 500 qaytaradi
raise GoogleAuthError(str(exc)) from exc
if not claims.get("email_verified", False):
raise GoogleAuthError("email_verified = false")
email = claims.get("email")
if not email:
# email scope so'ralmagan bo'lsa, claim umuman kelmaydi:
# claims["email"] bu yerda KeyError va 500 bo'lardi
raise GoogleAuthError("email claim yo'q")
return GoogleIdentity(
sub=claims["sub"],
email=email,
name=claims.get("name", ""),
picture=claims.get("picture", ""),
)
Funksiya qaytargan GoogleIdentity — endi ishonch bilan foydalanish mumkin
bo'lgan ma'lumot: u signature'dan, expdan, issdan va auddan o'tgan.
Shu to'rttadan bittasi tushib qolsa, qolgan uchtasi uning o'rnini qoplamaydi.
Eng nozik joyi esa — bitta argument.
audience=None — mana shu bitta so'z butun tekshiruvni o'chiradi
google-auth hujjatida aniq yozilgan: "If None then the audience is not
verified". Ya'ni signature to'g'ri, iss to'g'ri, exp to'g'ri —
lekin token butunlay boshqa ilova uchun berilgan bo'lishi mumkin.
Hujumchi o'zining Google client ID'si bilan qurilgan istalgan ilovadan
token olib, sizning backend'ingizga kiradi. Xavfsizlik uchun yozilgan kod
xavfsizlikni o'chirib qo'ygan bo'ladi. Qoida: audience argumentiga
doim allowlist beriladi, settings.GOOGLE_CLIENT_IDS or None kabi
"himoya" esa aynan teskari ishlaydi — ro'yxat bo'shab qolgan kuni
tekshiruv o'chadi.
Endi session haqida. google-auth hujjatining birinchi abzasi shuni aytadi:
"By default, this will re-fetch certificates for each verification". Ya'ni
har bir login https://www.googleapis.com/oauth2/v1/certs ga bitta tashqi
HTTPS so'rov degani. Foydalanuvchi uchun bu qo'shimcha kutish, siz uchun —
har login'ni Google'ning javob berishiga bog'lab qo'yish.
Cache — tezlik uchun emas, chidamlilik uchun
Google o'sha javobni Cache-Control: public, max-age=20191 bilan beradi —
ya'ni o'zi "bu kalitlarni olti soatcha saqlasang bo'ladi" deydi. Cache
qilish xavfsizlikni pasaytirmaydi. Cache'siz qolsangiz,
Google tomonda qisqa uzilish yuz berganda hamma login bir vaqtda
yiqiladi va sizning access log'ingiz aybdorni ko'rsatmaydi. Har gunicorn
worker'ining o'z process'i, demak o'z cache'i bo'ladi — bu normal, kalit
kichkina. Umumiy Redis cache kerak bo'ladigan hajm emas.
3-qadam: identity — sub bo'yicha, email bo'yicha emas#
Foydalanuvchini bazada sub claim bo'yicha taniymiz. sub — Google
hisobining o'zgarmas ID'si; email esa o'zgaradi (Google Workspace'da
ayniqsa). Email'ga bog'langan tizim, odam pochtasini o'zgartirgan kuni unga
yangi hisob ochib beradi — eski buyurtmalari, sevimli stansiyalari
"yo'qoladi".
from django.conf import settings
from django.db import models
class GoogleAccount(models.Model):
"""Google hisobi ↔ bizning User orasidagi bog'lanish."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="google_account",
)
# unique — nafaqat toza ma'lumot uchun: pastda race condition'ni
# aynan shu constraint to'xtatadi
sub = models.CharField(max_length=255, unique=True)
email = models.EmailField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.email} ({self.sub})"
Model bitta vazifani bajaradi: subni Userga bog'laydi va uni butun baza
bo'ylab yagona qilib qo'yadi. email bu yerda ham saqlanadi, lekin nusxa
sifatida — u qidiruv kaliti emas, oxirgi ko'rilgan qiymat. Nega buni
oddiygina usernamega yozib qo'ymaslik kerakligini alohida aytib o'taman.
Nega sub to'g'ridan-to'g'ri username maydoniga tiqilmaydi
Eng qisqa yo'l — subni username maydoniga tiqish. U ishlaydi, lekin
identity boshqa modulning maydonida yashiringan bo'ladi: ertaga Apple
Sign-In qo'shilsa, username formatini parse qilib qidirishga
tushasiz; admin panelda odam kimligi ko'rinmaydi; subni unique
qilishni esa Django'ning username unique'iga ishonib topshirasiz.
To'g'ri javob — USERNAME_FIELD = "email" bo'lgan custom User, lekin uni
loyihaning birinchi kuni qilish kerak edi: birinchi migratedan keyin
custom User'ga o'tish og'riqli ko'chirish. 4-qismda kech — shuning uchun
identity alohida modelda turadi, username esa shunchaki to'ldiriladigan
texnik maydon bo'lib qoladi.
Endi identitydan User yasaymiz. Ikki nozik joy bor: email har kirishda
yangilanadi (odam Google'da o'zgartirgan bo'lsa) va ikki qurilmadan bir
vaqtda kelgan birinchi login ikkita hisob yaratib qo'ymasligi kerak:
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from .google import GoogleIdentity
from .models import GoogleAccount
User = get_user_model()
def get_or_create_user(identity: GoogleIdentity) -> User:
account = GoogleAccount.objects.select_related("user").filter(sub=identity.sub).first()
if account is None:
try:
with transaction.atomic():
user = User.objects.create_user(
username=f"google-{identity.sub}",
email=identity.email,
first_name=identity.name[:150],
)
account = GoogleAccount.objects.create(
user=user, sub=identity.sub, email=identity.email
)
except IntegrityError:
# ikki qurilma bir vaqtda birinchi marta kirdi: ikkovi ham
# "yo'q ekan" deb topdi, ikkinchi INSERT unique(sub) ga urildi.
# transaction.atomic bo'lmasa — yarim yaratilgan User qolib ketadi
account = GoogleAccount.objects.select_related("user").get(sub=identity.sub)
elif account.email != identity.email:
account.email = identity.email
account.save(update_fields=["email"])
User.objects.filter(pk=account.user_id).update(email=identity.email)
return account.user
Funksiyada birorta joyda email bo'yicha qidiruv yo'qligiga e'tibor bering —
bu ataylab shunday. GoogleAccount.objects.filter(email=...) bu yerga bitta
qator bo'lib qo'shiladi va butun tizimni boshqa xavfsizlik sinfiga tushirib
yuboradi.
Email bo'yicha avtomatik bog'lash — account takeover'ning klassik yo'li
"Bu email allaqachon bazada bor ekan, o'sha hisobga ulab yuboraman" degan
bir qator kod — eng ko'p uchraydigan zaifliklardan biri. Provayder
email'ni tasdiqlamagan bo'lsa, hujumchi qurbonning email'i bilan hisob
ochib, uning kabinetiga kiradi. Biz email_verifiedni tekshiramiz, lekin
qoida baribir o'z kuchida: bog'lash — foydalanuvchining ongli amali.
Mavjud hisobga Google'ni ulash kerak bo'lsa, buni kirgan foydalanuvchi
o'z profilidan qiladi, login endpoint'i emas.
4-qadam: endpoint#
View'ning o'zi qisqa: verify qil, xatolarni to'g'ri status'ga ayir, token
qaytar. Diqqat qiladigan joy — throttle_classes. Bu ochiq endpoint: har
so'rov crypto hisob-kitob, cache sovuq bo'lsa tashqi so'rov ham degani.
Throttle'siz qoldirilgan login endpoint'i — arzon DoS nishoni:
from rest_framework import serializers
class GoogleAuthSerializer(serializers.Serializer):
id_token = serializers.CharField()
Serializer bitta ish qiladi: id_token maydoni umuman kelganini tekshiradi.
U bo'lmasa, DRF 400 qaytaradi va biz bo'sh satrni verify qilishga
urinmaymiz. 400 bilan 401 bu yerda har xil hodisa: birinchisi — mobil
ilovaning bug'i, ikkinchisi — token'ning o'zi yaroqsizligi.
import logging
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from .google import GoogleAuthError, GoogleUnavailable, verify_google_token
from .serializers import GoogleAuthSerializer
from .services import get_or_create_user
logger = logging.getLogger(__name__)
class GoogleAuthThrottle(AnonRateThrottle):
scope = "google_auth"
class GoogleAuthView(APIView):
permission_classes = [AllowAny]
throttle_classes = [GoogleAuthThrottle]
def post(self, request):
serializer = GoogleAuthSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
identity = verify_google_token(serializer.validated_data["id_token"])
except GoogleUnavailable as exc:
# 401 bersak, mobil ilova foydalanuvchini tizimdan chiqarib
# yuboradi — aybi yo'q odamni. 503 esa "keyinroq urinib ko'ring"
logger.error("Google certs endpoint javob bermadi: %s", exc)
return Response(
{"detail": "Google bilan bog'lanib bo'lmadi"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except GoogleAuthError as exc:
# sababni client'ga ochmaymiz — hujumchi uchun qimmatli ma'lumot.
# Log'ga ham raw token EMAS, faqat sabab yoziladi:
# log fayli o'g'irlansa, token bilan birga hisob ham ketadi
logger.warning("id_token rad etildi: %s", exc)
return Response(
{"detail": "id_token yaroqsiz"},
status=status.HTTP_401_UNAUTHORIZED,
)
user = get_or_create_user(identity)
refresh = RefreshToken.for_user(user)
return Response({"access": str(refresh.access_token), "refresh": str(refresh)})
Qolgani — URL va migratsiya. Endpoint manzili config/urls.py dagi ro'yxatga
ulanadi: path("api/", include("apps.accounts.urls")).
from django.urls import path
from .views import GoogleAuthView
urlpatterns = [
path("auth/google/", GoogleAuthView.as_view()),
]
accounts endi model'li app — migratsiyani unutmang. Aks holda birinchi
login relation "accounts_googleaccount" does not exist bilan yiqiladi va
xato log'da Google bilan bog'liqdek ko'rinadi, aslida sabab bazada:
uv run python manage.py makemigrations accounts
uv run python manage.py migrate
Mobil tomondan bu bitta so'rov. Lokal muhitda http:// bilan sinaysiz,
production'da esa faqat HTTPS: id_token ham, javobdagi access/refresh ham
ochiq matnda ketadi, ularni ushlagan odam hisobni qo'lga oladi:
curl -X POST http://127.0.0.1:8000/api/auth/google/ \
-H "Content-Type: application/json" \
-d '{"id_token": "eyJhbGciOiJSUzI1NiIs..."}'
# javob:
# {"access": "eyJ...", "refresh": "eyJ..."}
Javobdan keyin ilova barcha so'rovlarni Authorization: Bearer <access>
header'i bilan yuboradi — 3-qismdagi
mehmon-qurilma ham endi userga bog'lanadi (login paytida ilova token
ro'yxatga olish endpoint'ini qayta chaqiradi). Buning uchun DRF'ga Bearer
header'ini o'qishni aytish kerak — simplejwt'ni o'rnatishning o'zi yetmaydi:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
# GoogleAuthThrottle.scope shu yerdan qiymat oladi
"DEFAULT_THROTTLE_RATES": {"google_auth": "10/min"},
}
Bu sozlamasiz /api/devices/ Bearer token'ni jimgina e'tiborsiz qoldiradi:
javob baribir 201, lekin request.user anonim va qurilma hech qachon
foydalanuvchiga bog'lanmaydi. Sozlama bilan mehmon (header'siz) so'rovlar
avvalgidek o'tadi, yaroqsiz Bearer esa 401 oladi.
5-qadam: testlar — Google'siz#
Tarmoqqa chiqmasdan verify_oauth2_tokenni mock qilamiz. Lekin mock bilan
bitta xavf bor: kutubxonani almashtirib qo'ysak, unga qanday argument
berganimiz tekshirilmay qoladi — ya'ni audience yo'qolib qolsa ham
testlar yashil bo'laveradi. Shuning uchun birinchi testda call_argsni ham
tekshiramiz:
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from django.core.cache import cache
from google.auth import exceptions as google_exceptions
from apps.accounts.models import GoogleAccount
VERIFY = "apps.accounts.google.google_id_token.verify_oauth2_token"
CLAIMS = {
"sub": "1122334455",
"aud": "test-web.apps.googleusercontent.com",
"email": "user@misol.uz",
"email_verified": True,
"name": "Test User",
}
@pytest.fixture
def client_ids(settings):
settings.GOOGLE_CLIENT_IDS = ["test-web.apps.googleusercontent.com"]
# throttle hisoblagichi cache'da turadi: tozalamasak, testlar
# bir-birini 429 qilib yiqitadi
cache.clear()
def login(client):
return client.post("/api/auth/google/", {"id_token": "raw"}, content_type="application/json")
@patch(VERIFY, return_value=CLAIMS)
def test_valid_token_creates_user_and_returns_jwt(mock_verify, client, client_ids, db):
response = login(client)
assert response.status_code == 200
assert {"access", "refresh"} <= response.json().keys()
# aud tekshiruvi kutubxonaga TOPSHIRILGANINI qayd qilamiz
assert mock_verify.call_args.kwargs["audience"] == ["test-web.apps.googleusercontent.com"]
assert GoogleAccount.objects.get(sub="1122334455").user.email == "user@misol.uz"
@pytest.mark.parametrize(
"error",
[
google_exceptions.InvalidValue("Token has wrong audience"),
google_exceptions.GoogleAuthError("Wrong issuer."),
ValueError("Could not verify token signature."),
],
)
def test_invalid_token_is_401_and_creates_nothing(error, client, client_ids, db):
with patch(VERIFY, side_effect=error):
response = login(client)
assert response.status_code == 401
assert get_user_model().objects.count() == 0
@patch(VERIFY, side_effect=google_exceptions.TransportError("certs unreachable"))
def test_google_down_is_503_not_401(mock_verify, client, client_ids, db):
# foydalanuvchi aybdor emas — uni tizimdan chiqarib yubormaymiz
assert login(client).status_code == 503
@patch(VERIFY, return_value={**CLAIMS, "email_verified": False})
def test_unverified_email_rejected(mock_verify, client, client_ids, db):
assert login(client).status_code == 401
@patch(VERIFY, return_value=CLAIMS)
def test_second_login_reuses_the_same_user(mock_verify, client, client_ids, db):
login(client)
login(client)
assert get_user_model().objects.count() == 1
assert GoogleAccount.objects.count() == 1
Bu besh test butun zanjirni qoplaydi: to'g'ri token, uch xil yaroqsiz token, Google'ning uzilishi, tasdiqlanmagan email va takroriy login. Qo'lda tekshiriladigan yagona narsa qoladi — haqiqiy qurilmadan kelgan haqiqiy token, ikkala platformada ham:
uv run pytest apps/accounts/ -q
Xulosa#
id_tokenga faqat verify'dan keyin ishoniladi —decode≠verify;-
audienceargumentiga allowlist beriladi;None—audtekshiruvini butunlay o'chiradi; -
public key'lar cache'lanadi:
cachecontrolsession'siz har login Google'ga tashqi so'rov va Google uzilsa hamma login yiqiladi; -
identity kaliti —
sub; email o'zgaruvchan ko'rsatkich, unga bog'lash account takeover'ga olib boradi; -
Google javob bermasa
503, token yaroqsiz bo'lsa401— ikkovi ikki xil hodisa; -
ochiq login endpoint'i throttle bilan yopiladi, xato sabablari esa log'da qoladi, javobda emas.
Seriyaning yakuniy qismida umumlashtiramiz: backend'ni mobil ilovaga moslab dizayn qilish — versiyalash, payload hajmi, sekin tarmoqlar va offline'ga chidamlilik.