Asosiy kontentga o‘tish

Maqolalar

Maqola · 2026-08-16 · ~11 daqiqa o‘qiladi · murakkab

Google Sign-In: iOS va Android tokenini backend'da to'g'ri tekshirish

#django #drf #oauth #jwt #security #mobile

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 haqiqiyligini tekshirmasak — 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) token imzosini Google kaliti bilan tekshirish
                                     2) token AYNAN BIZNING ilovaga berilganini tekshirish
                                     3) foydalanuvchini topish/yaratish
                                     4) o'zimizning access/refresh JWT'ni qaytarish

Eng xavfli xato: tokenni shunchaki decode qilish

id_token — oddiy JWT, uni istalgan kutubxona imzosiz ham o'qib beradi (jwt.decode(..., options={"verify_signature": False})). Ichidan chiroyli email chiqadi — va tekshirmasdan ishongan backend'ga istalgan qalbaki token o'tadi. Token faqat Google'ning ochiq kaliti bilan imzo tekshiruvidan o'tgandagina haqiqiy. JWT tuzilishini eslash uchun: JWT autentifikatsiya.

Nega o'z JWT'imizni beramiz#

Google tokeni — shaxsni isbotlash hujjati, API'ga kirish chiptasi emas: muddati ~1 soat, uni yangilash Google SDK'ga bog'liq, ichida bizning rollarimiz/ID'larimiz yo'q. Shuning uchun sxema ikki bosqichli: Google tokenini bir marta tekshiramiz, keyin foydalanuvchiga o'zimizning access/refresh juftligimizni beramiz — API hayoti oddiy JWT autentifikatsiya bilan davom etadi.

1-qadam: sozlamalar — ikki platforma, ikki client ID#

Google Cloud Console'da mobil jamoa ikkita OAuth client yaratadi: biri Android, biri iOS uchun. Ikkalasining client ID'si har xil — va ikkalasidan kelgan token ham bizga yaroqli bo'lishi kerak:

uv add google-auth djangorestframework-simplejwt
# config/settings.py
import os

# vergul bilan: "123-android.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()
]

«Android'da ishlayapti, iOS'da 401» sindromi

Eng ko'p uchraydigan production bug'i: sozlamada faqat Android client ID turadi, iOS'dan kelgan tokenning aud maydoni esa boshqa — tekshiruv yiqiladi. Ro'yxat qilib qo'ying va ikkala platformani testda qamrang.

2-qadam: tekshiruv funksiyasi#

apps/accounts/
├── __init__.py
├── models.py        ← standart User (yoki custom) — bu maqolada o'zgarmaydi
├── google.py        ← id_token tekshiruvi (hozir shu yerdamiz)
├── serializers.py
├── views.py
└── tests.py
# apps/accounts/google.py
from dataclasses import dataclass

from django.conf import settings
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token as google_id_token


class GoogleAuthError(Exception):
    """Token yaroqsiz — sababi foydalanuvchiga batafsil aytilmaydi."""


@dataclass(frozen=True)
class GoogleIdentity:
    sub: str  # Google'ning O'ZGARMAS foydalanuvchi ID'si
    email: str
    name: str
    picture: str


def verify_google_token(raw_token: str) -> GoogleIdentity:
    try:
        # verify_oauth2_token uch ishni birdan qiladi:
        # 1) Google JWKS kalitlari bilan IMZO tekshiruvi (kalitlarni keshlaydi)
        # 2) muddat (exp) tekshiruvi
        # 3) issuer accounts.google.com ekani
        claims = google_id_token.verify_oauth2_token(
            raw_token,
            google_requests.Request(),
            # audience=None — aud'ni o'zimiz tekshiramiz (ro'yxat bilan)
            audience=None,
        )
    except ValueError as exc:
        raise GoogleAuthError("token imzosi yoki muddati yaroqsiz") from exc

    # aud — token qaysi ilovaga berilgan. Bizning ikki client ID'dan biri
    # bo'lmasa, bu token BOSHQA ilova uchun olingan — qabul qilmaymiz
    if claims.get("aud") not in settings.GOOGLE_CLIENT_IDS:
        raise GoogleAuthError("token boshqa ilovaga tegishli")

    if not claims.get("email_verified", False):
        raise GoogleAuthError("email tasdiqlanmagan")

    return GoogleIdentity(
        sub=claims["sub"],
        email=claims["email"],
        name=claims.get("name", ""),
        picture=claims.get("picture", ""),
    )

sub — ha, email — yo'q

Foydalanuvchini bazada sub bo'yicha taniymiz, email bo'yicha emas. Email o'zgarishi mumkin (Google Workspace'da ayniqsa) — o'zgargan kuni email'ga bog'langan tizim o'sha odamga yangi hisob ochib beradi, eski ma'lumotlari "yo'qoladi". sub esa hisob umri davomida o'zgarmas.

3-qadam: foydalanuvchi va endpoint#

# apps/accounts/serializers.py
from rest_framework import serializers


class GoogleAuthSerializer(serializers.Serializer):
    id_token = serializers.CharField()
# apps/accounts/views.py
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken

from .google import GoogleAuthError, verify_google_token
from .serializers import GoogleAuthSerializer

User = get_user_model()


class GoogleAuthView(APIView):
    permission_classes = [AllowAny]

    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 GoogleAuthError:
            # sababni ochmaymiz — hujumchi uchun qimmatli ma'lumot bo'lardi
            return Response(
                {"detail": "Google token yaroqsiz"},
                status=status.HTTP_401_UNAUTHORIZED,
            )

        # username sifatida sub — o'zgarmas kalit (yuqoridagi eslatma).
        # email/ism har kirishda yangilanadi: foydalanuvchi Google'da
        # o'zgartirgan bo'lsa, bizda ham yangi ko'rinadi
        user, _ = User.objects.update_or_create(
            username=f"google:{identity.sub}",
            defaults={"email": identity.email, "first_name": identity.name[:150]},
        )

        refresh = RefreshToken.for_user(user)
        return Response({"access": str(refresh.access_token), "refresh": str(refresh)})
# apps/accounts/urls.py
from django.urls import path

from .views import GoogleAuthView

urlpatterns = [
    path("auth/google/", GoogleAuthView.as_view()),
]

Mobil tomondan bu bitta so'rov:

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..."}

Shu javobdan keyin ilova barcha so'rovlarni Authorization: Bearer <access> bilan yuboradi — 3-qismdagi mehmon-qurilma ham endi userga bog'lanadi (login paytida ilova token ro'yxatga olish endpoint'ini qayta chaqiradi).

4-qadam: test — Google'siz#

Tarmoqqa chiqmasdan verify_oauth2_tokenni mock qilamiz:

# apps/accounts/tests.py
from unittest.mock import patch

import pytest
from django.contrib.auth import get_user_model

CLAIMS = {
    "sub": "1122334455",
    "aud": "test-android.apps.googleusercontent.com",
    "email": "user@misol.uz",
    "email_verified": True,
    "name": "Test User",
}


@pytest.fixture
def client_ids(settings):
    settings.GOOGLE_CLIENT_IDS = ["test-android.apps.googleusercontent.com"]


@patch("apps.accounts.google.google_id_token.verify_oauth2_token", return_value=CLAIMS)
def test_valid_token_creates_user_and_returns_jwt(mock_verify, client, client_ids, db):
    response = client.post(
        "/api/auth/google/", {"id_token": "raw"}, content_type="application/json"
    )
    assert response.status_code == 200
    assert {"access", "refresh"} <= response.json().keys()
    user = get_user_model().objects.get(username="google:1122334455")
    assert user.email == "user@misol.uz"


@patch(
    "apps.accounts.google.google_id_token.verify_oauth2_token",
    return_value={**CLAIMS, "aud": "hujumchi-ilova.apps.googleusercontent.com"},
)
def test_foreign_audience_rejected(mock_verify, client, client_ids, db):
    response = client.post(
        "/api/auth/google/", {"id_token": "raw"}, content_type="application/json"
    )
    assert response.status_code == 401
    assert get_user_model().objects.count() == 0

Takroriy kirish — takroriy foydalanuvchi emas

update_or_create(username=f"google:{sub}") ikkinchi kirishda yangi qator yaratmaydi — mavjudini yangilaydi. Buni ham testlab qo'ygan ma'qul: bitta sub bilan ikki marta kirib, User.objects.count() == 1 ekanini tekshiring.

Xulosa#

  • id_tokenga faqat imzo tekshiruvidan keyin ishoniladi — decode ≠ verify;
  • aud ro'yxat bilan tekshiriladi: iOS va Android client ID'lari har xil;
  • Foydalanuvchi kaliti — sub; email — o'zgaruvchan ko'rsatkich;
  • Google tokeni bir martalik isbot, API'da esa o'z access/refresh JWT'ingiz ishlaydi;
  • Xato sabablarini mijozga batafsil qaytarmang — 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.