# ЗАДАЧА: Реализовать авторизацию через Google OAuth 2.0 ## КОНТЕКСТ ПРОЕКТА Проект **Steppe Sonorum: Piano** — цифровой каталог казахских фортепианных композиций. Стек: - Backend: Django 5.x + Django REST Framework, PostgreSQL - Frontend: Next.js 14+ (App Router), Tailwind CSS - Сервер: Ubuntu 24.04, Nginx, домен https://kitan-a.com/ Структура репозитория — монорепо: ``` steppe-sonorum/ ├── backend/ # Django │ ├── config/ # settings, urls │ └── catalog/ # основное приложение каталога ├── frontend/ # Next.js └── ... ``` Авторизация нужна для будущих фич: избранное, коллекции, комментарии, предложение правок. Сейчас сайт полностью публичный (каталог доступен без логина). Авторизация НЕ должна блокировать доступ к каталогу — только добавлять возможности залогиненным пользователям. --- ## ЧТО НУЖНО РЕАЛИЗОВАТЬ ### 1. Backend (Django) **Установить и настроить пакеты:** ``` django-allauth[socialaccount] dj-rest-auth[with_social] djangorestframework-simplejwt ``` **Создать новое Django приложение `accounts`:** ``` backend/ ├── accounts/ │ ├── models.py # Расширенная модель пользователя │ ├── serializers.py # User serializer │ ├── views.py # Профиль, Google callback │ ├── urls.py │ └── admin.py ``` **Модель пользователя — расширить стандартную:** ```python from django.contrib.auth.models import AbstractUser class User(AbstractUser): avatar_url = models.URLField(blank=True, default="") # Google profile photo display_name = models.CharField(max_length=255, blank=True) # Имя для отображения bio = models.TextField(blank=True, default="") # О себе (опционально) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return self.display_name or self.username ``` ВАЖНО: Задать `AUTH_USER_MODEL = 'accounts.User'` в settings.py ПЕРЕД первой миграцией. Если миграции уже существуют — нужно сбросить БД или мигрировать аккуратно. **Настройки Django (config/settings/base.py):** ```python INSTALLED_APPS = [ # ... существующие приложения ... 'django.contrib.sites', 'allauth', 'allauth.account', 'allauth.socialaccount', 'allauth.socialaccount.providers.google', 'rest_framework', 'rest_framework.authtoken', 'dj_rest_auth', 'dj_rest_auth.registration', 'accounts', ] SITE_ID = 1 # REST Framework — JWT авторизация REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework.authentication.SessionAuthentication', # Для Django admin ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.AllowAny', # Каталог публичный по умолчанию ], } # JWT настройки from datetime import timedelta SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), 'ROTATE_REFRESH_TOKENS': True, 'AUTH_HEADER_TYPES': ('Bearer',), } # dj-rest-auth — использовать JWT вместо Token REST_AUTH = { 'USE_JWT': True, 'JWT_AUTH_COOKIE': 'access_token', 'JWT_AUTH_REFRESH_COOKIE': 'refresh_token', 'JWT_AUTH_HTTPONLY': True, # HttpOnly cookie — безопасно 'JWT_AUTH_SAMESITE': 'Lax', 'JWT_AUTH_SECURE': True, # True для HTTPS (production) 'USER_DETAILS_SERIALIZER': 'accounts.serializers.UserSerializer', } # allauth ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_AUTHENTICATION_METHOD = 'email' ACCOUNT_EMAIL_VERIFICATION = 'none' # Не требовать верификацию email при входе через Google SOCIALACCOUNT_AUTO_SIGNUP = True # Автоматически создавать юзера при первом входе SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Google OAuth — значения из переменных окружения SOCIALACCOUNT_PROVIDERS = { 'google': { 'SCOPE': ['profile', 'email'], 'AUTH_PARAMS': {'access_type': 'online'}, 'APP': { 'client_id': os.environ.get('GOOGLE_CLIENT_ID', ''), 'secret': os.environ.get('GOOGLE_CLIENT_SECRET', ''), }, } } # Для allauth MIDDLEWARE = [ # ... существующие middleware ... 'allauth.account.middleware.AccountMiddleware', ] # Куда редиректить после логина (allauth использует, но мы перехватим на фронте) LOGIN_REDIRECT_URL = '/' ``` **URL маршруты (config/urls.py):** ```python urlpatterns = [ path('admin/', admin.site.urls), path('api/v1/', include('catalog.urls')), path('api/v1/auth/', include('accounts.urls')), ] ``` **URL маршруты (accounts/urls.py):** ```python from django.urls import path, include from .views import GoogleLogin, UserProfileView urlpatterns = [ # dj-rest-auth базовые эндпоинты path('', include('dj_rest_auth.urls')), # Google OAuth path('google/', GoogleLogin.as_view(), name='google_login'), # Профиль текущего пользователя path('profile/', UserProfileView.as_view(), name='user_profile'), ] ``` Итоговые эндпоинты: ``` POST /api/v1/auth/google/ # Принимает Google access_token или code, возвращает JWT GET /api/v1/auth/user/ # Текущий пользователь (dj-rest-auth built-in) POST /api/v1/auth/token/refresh/ # Обновить JWT POST /api/v1/auth/logout/ # Выход GET /api/v1/auth/profile/ # Расширенный профиль PUT /api/v1/auth/profile/ # Обновить профиль ``` **Views (accounts/views.py):** ```python from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter from allauth.socialaccount.providers.oauth2.client import OAuth2Client from dj_rest_auth.registration.views import SocialLoginView from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import IsAuthenticated from .serializers import UserSerializer class GoogleLogin(SocialLoginView): adapter_class = GoogleOAuth2Adapter callback_url = "https://kitan-a.com/auth/callback" # URL фронтенда client_class = OAuth2Client class UserProfileView(RetrieveUpdateAPIView): serializer_class = UserSerializer permission_classes = [IsAuthenticated] def get_object(self): return self.request.user ``` **Serializers (accounts/serializers.py):** ```python from rest_framework import serializers from .models import User class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['id', 'email', 'display_name', 'avatar_url', 'bio', 'created_at'] read_only_fields = ['id', 'email', 'created_at'] ``` **Adapter для автозаполнения данных из Google (accounts/adapter.py):** ```python from allauth.socialaccount.adapter import DefaultSocialAccountAdapter class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): def populate_user(self, request, sociallogin, data): user = super().populate_user(request, sociallogin, data) user.display_name = data.get('name', '') extra_data = sociallogin.account.extra_data user.avatar_url = extra_data.get('picture', '') return user ``` В settings.py добавить: ```python SOCIALACCOUNT_ADAPTER = 'accounts.adapter.CustomSocialAccountAdapter' ``` --- ### 2. Google Cloud Console — настройка OAuth Это делается вручную (не кодом). Инструкции для владельца проекта: 1. Перейти на https://console.cloud.google.com/ 2. Создать проект (или выбрать существующий) 3. APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID 4. Application type: Web application 5. Authorized JavaScript origins: - https://kitan-a.com - http://localhost:3000 (для разработки) 6. Authorized redirect URIs: - https://kitan-a.com/auth/callback - http://localhost:3000/auth/callback (для разработки) 7. Скопировать Client ID и Client Secret 8. Добавить в .env файл на сервере: ``` GOOGLE_CLIENT_ID=xxxxxxxxxxxxx.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx ``` 9. APIs & Services → OAuth consent screen: - User Type: External - App name: Steppe Sonorum: Piano - User support email: (ваш email) - Authorized domains: kitan-a.com - Scopes: email, profile --- ### 3. Frontend (Next.js) **Установить пакеты:** ```bash npm install @react-oauth/google js-cookie ``` **Переменные окружения (frontend/.env.local):** ``` NEXT_PUBLIC_API_URL=https://kitan-a.com/api/v1 NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxxxxxxxxxxxx.apps.googleusercontent.com ``` **Auth контекст (src/context/AuthContext.tsx):** ```tsx 'use client'; import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import Cookies from 'js-cookie'; interface User { id: number; email: string; display_name: string; avatar_url: string; bio: string; } interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; loginWithGoogle: (credential: string) => Promise; logout: () => Promise; refreshUser: () => Promise; } const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const API_URL = process.env.NEXT_PUBLIC_API_URL; // Загрузить данные пользователя при старте const refreshUser = useCallback(async () => { try { const res = await fetch(`${API_URL}/auth/user/`, { credentials: 'include', // Отправляет HttpOnly cookies }); if (res.ok) { const data = await res.json(); setUser(data); } else { setUser(null); } } catch { setUser(null); } finally { setIsLoading(false); } }, [API_URL]); useEffect(() => { refreshUser(); }, [refreshUser]); // Логин через Google — отправить credential (ID token) на бэкенд const loginWithGoogle = async (credential: string) => { setIsLoading(true); try { const res = await fetch(`${API_URL}/auth/google/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ access_token: credential }), }); if (res.ok) { await refreshUser(); } else { const error = await res.json(); console.error('Google login failed:', error); throw new Error('Login failed'); } } finally { setIsLoading(false); } }; // Выход const logout = async () => { try { await fetch(`${API_URL}/auth/logout/`, { method: 'POST', credentials: 'include', }); } finally { setUser(null); } }; return ( {children} ); } export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be used within AuthProvider'); return context; } ``` **Обернуть приложение в AuthProvider (src/app/layout.tsx):** ```tsx import { AuthProvider } from '@/context/AuthContext'; import { GoogleOAuthProvider } from '@react-oauth/google'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {/* Header, навигация и т.д. */} {children} ); } ``` **Кнопка входа в хедере (src/components/AuthButton.tsx):** ```tsx 'use client'; import { useAuth } from '@/context/AuthContext'; import { GoogleLogin } from '@react-oauth/google'; import { useState, useRef, useEffect } from 'react'; export default function AuthButton() { const { user, isAuthenticated, isLoading, loginWithGoogle, logout } = useAuth(); const [showDropdown, setShowDropdown] = useState(false); const dropdownRef = useRef(null); // Закрыть dropdown при клике вне useEffect(() => { function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setShowDropdown(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); if (isLoading) { return
; } // Не авторизован — показать кнопку Google if (!isAuthenticated) { return ( { if (credentialResponse.credential) { loginWithGoogle(credentialResponse.credential); } }} onError={() => console.error('Google login error')} size="medium" shape="pill" text="signin" theme="outline" /> ); } // Авторизован — показать аватар + dropdown return (
{showDropdown && (
{/* Инфо о пользователе */}

{user?.display_name}

{user?.email}

{/* Навигация */} Мой профиль Избранное Коллекции {/* Выход */}
)}
); } ``` **Использование в хедере:** ```tsx // В компоненте Header, рядом с переключателем языка import AuthButton from '@/components/AuthButton'; // ...внутри header JSX:
``` --- ### 4. CORS настройка В Django settings (backend/config/settings/base.py): ```bash pip install django-cors-headers ``` ```python INSTALLED_APPS = [ # ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # ПЕРВЫМ в списке! 'django.middleware.common.CommonMiddleware', # ... ] # Development CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", "https://kitan-a.com", ] CORS_ALLOW_CREDENTIALS = True # Для HttpOnly cookies ``` --- ### 5. Миграции и инициализация ```bash cd backend python manage.py makemigrations accounts python manage.py migrate # Создать Site object (нужен для allauth) python manage.py shell -c " from django.contrib.sites.models import Site site = Site.objects.get_or_create(id=1, defaults={'domain': 'kitan-a.com', 'name': 'Steppe Sonorum'}) print('Site created:', site) " ``` --- ## ВАЖНЫЕ ПРАВИЛА 1. **Каталог остаётся публичным.** `DEFAULT_PERMISSION_CLASSES = ['AllowAny']`. Только эндпоинты профиля, избранного, комментариев требуют `IsAuthenticated`. 2. **JWT хранится в HttpOnly cookie**, НЕ в localStorage. Это безопаснее — JavaScript на фронте не имеет доступа к токену. 3. **CSRF**: При использовании cookies нужно учитывать CSRF. Django REST Framework + `SessionAuthentication` требует CSRF token. Для JWT в cookies — `dj-rest-auth` обрабатывает это автоматически через `JWT_AUTH_HTTPONLY` и `JWT_AUTH_SAMESITE`. 4. **При первом входе через Google** автоматически создаётся User с данными из Google профиля (имя, email, фото). Юзеру НЕ нужно заполнять формы регистрации. 5. **Не делать регистрацию по email/password.** Только Google OAuth. Это упрощает систему и избавляет от необходимости верификации email, сброса пароля, и т.д. 6. **Аватар** берётся из Google profile photo URL и сохраняется в `avatar_url`. Не скачивать файл — просто хранить URL. 7. **Стилизация кнопки Google** — использовать встроенный компонент `` из `@react-oauth/google`. Он соответствует гайдлайнам Google и адаптивен. Не рисовать кастомную кнопку. --- ## ТЕСТИРОВАНИЕ 1. Открыть сайт → в хедере должна быть кнопка "Sign in with Google" 2. Нажать → появляется Google popup → выбрать аккаунт 3. После успешного входа → кнопка заменяется на аватар пользователя 4. Нажать на аватар → dropdown с именем, email, ссылками на профиль/избранное, кнопка выхода 5. Обновить страницу → пользователь остаётся залогиненным (JWT в cookie) 6. Нажать "Выйти" → возвращается кнопка "Sign in with Google" 7. Каталог, страницы композиций, композиторов — всё доступно без логина --- ## ФАЙЛЫ КОТОРЫЕ НУЖНО СОЗДАТЬ/ИЗМЕНИТЬ ### Создать: - `backend/accounts/__init__.py` - `backend/accounts/models.py` - `backend/accounts/serializers.py` - `backend/accounts/views.py` - `backend/accounts/urls.py` - `backend/accounts/admin.py` - `backend/accounts/adapter.py` - `backend/accounts/apps.py` - `frontend/src/context/AuthContext.tsx` - `frontend/src/components/AuthButton.tsx` ### Изменить: - `backend/config/settings/base.py` — добавить приложения, настройки allauth, JWT, CORS - `backend/config/urls.py` — добавить маршрут auth - `backend/requirements.txt` — добавить пакеты - `frontend/package.json` — добавить зависимости - `frontend/src/app/layout.tsx` — обернуть в провайдеры - Компонент Header — добавить `` - `.env` — добавить GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET