| # ЗАДАЧА: Реализовать авторизацию через 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 |
|
|