| # ЗАДАЧА: Реализовать авторизацию через 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<void>; | |
| logout: () => Promise<void>; | |
| refreshUser: () => Promise<void>; | |
| } | |
| const AuthContext = createContext<AuthContextType | undefined>(undefined); | |
| export function AuthProvider({ children }: { children: React.ReactNode }) { | |
| const [user, setUser] = useState<User | null>(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 ( | |
| <AuthContext.Provider value={{ | |
| user, | |
| isLoading, | |
| isAuthenticated: !!user, | |
| loginWithGoogle, | |
| logout, | |
| refreshUser, | |
| }}> | |
| {children} | |
| </AuthContext.Provider> | |
| ); | |
| } | |
| 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 ( | |
| <html> | |
| <body> | |
| <GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}> | |
| <AuthProvider> | |
| {/* Header, навигация и т.д. */} | |
| {children} | |
| </AuthProvider> | |
| </GoogleOAuthProvider> | |
| </body> | |
| </html> | |
| ); | |
| } | |
| ``` | |
| **Кнопка входа в хедере (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<HTMLDivElement>(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 <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />; | |
| } | |
| // Не авторизован — показать кнопку Google | |
| if (!isAuthenticated) { | |
| return ( | |
| <GoogleLogin | |
| onSuccess={(credentialResponse) => { | |
| if (credentialResponse.credential) { | |
| loginWithGoogle(credentialResponse.credential); | |
| } | |
| }} | |
| onError={() => console.error('Google login error')} | |
| size="medium" | |
| shape="pill" | |
| text="signin" | |
| theme="outline" | |
| /> | |
| ); | |
| } | |
| // Авторизован — показать аватар + dropdown | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <button | |
| onClick={() => setShowDropdown(!showDropdown)} | |
| className="flex items-center gap-2 hover:opacity-80 transition" | |
| > | |
| {user?.avatar_url ? ( | |
| <img | |
| src={user.avatar_url} | |
| alt={user.display_name} | |
| className="w-8 h-8 rounded-full border-2 border-white" | |
| /> | |
| ) : ( | |
| <div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-medium"> | |
| {user?.display_name?.charAt(0) || '?'} | |
| </div> | |
| )} | |
| </button> | |
| {showDropdown && ( | |
| <div className="absolute right-0 top-full mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50"> | |
| {/* Инфо о пользователе */} | |
| <div className="px-4 py-2 border-b border-gray-100"> | |
| <p className="text-sm font-medium text-gray-900">{user?.display_name}</p> | |
| <p className="text-xs text-gray-500">{user?.email}</p> | |
| </div> | |
| {/* Навигация */} | |
| <a href="/profile" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> | |
| Мой профиль | |
| </a> | |
| <a href="/favorites" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> | |
| Избранное | |
| </a> | |
| <a href="/collections" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> | |
| Коллекции | |
| </a> | |
| {/* Выход */} | |
| <div className="border-t border-gray-100 mt-1"> | |
| <button | |
| onClick={() => { logout(); setShowDropdown(false); }} | |
| className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50" | |
| > | |
| Выйти | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| ``` | |
| **Использование в хедере:** | |
| ```tsx | |
| // В компоненте Header, рядом с переключателем языка | |
| import AuthButton from '@/components/AuthButton'; | |
| // ...внутри header JSX: | |
| <div className="flex items-center gap-4"> | |
| <LanguageSwitcher /> | |
| <AuthButton /> | |
| </div> | |
| ``` | |
| --- | |
| ### 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** — использовать встроенный компонент `<GoogleLogin />` из `@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 — добавить `<AuthButton />` | |
| - `.env` — добавить GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | |