temp / google_auth_spec.md
CCRss's picture
Upload google_auth_spec.md
7bc4c7e verified

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

Модель пользователя — расширить стандартную:

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):

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):

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('catalog.urls')),
    path('api/v1/auth/', include('accounts.urls')),
]

URL маршруты (accounts/urls.py):

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):

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):

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):

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 добавить:

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:
  6. Authorized redirect URIs:
  7. Скопировать Client ID и Client Secret
  8. Добавить в .env файл на сервере:
GOOGLE_CLIENT_ID=xxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
  1. 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)

Установить пакеты:

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):

'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):

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):

'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>
  );
}

Использование в хедере:

// В компоненте 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):

pip install django-cors-headers
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. Миграции и инициализация

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