ЗАДАЧА: Реализовать авторизацию через 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
Это делается вручную (не кодом). Инструкции для владельца проекта:
- Перейти на https://console.cloud.google.com/
- Создать проект (или выбрать существующий)
- APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID
- Application type: Web application
- Authorized JavaScript origins:
- https://kitan-a.com
- http://localhost:3000 (для разработки)
- Authorized redirect URIs:
- https://kitan-a.com/auth/callback
- http://localhost:3000/auth/callback (для разработки)
- Скопировать Client ID и Client Secret
- Добавить в .env файл на сервере:
GOOGLE_CLIENT_ID=xxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
- 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)
"
ВАЖНЫЕ ПРАВИЛА
Каталог остаётся публичным.
DEFAULT_PERMISSION_CLASSES = ['AllowAny']. Только эндпоинты профиля, избранного, комментариев требуютIsAuthenticated.JWT хранится в HttpOnly cookie, НЕ в localStorage. Это безопаснее — JavaScript на фронте не имеет доступа к токену.
CSRF: При использовании cookies нужно учитывать CSRF. Django REST Framework +
SessionAuthenticationтребует CSRF token. Для JWT в cookies —dj-rest-authобрабатывает это автоматически черезJWT_AUTH_HTTPONLYиJWT_AUTH_SAMESITE.При первом входе через Google автоматически создаётся User с данными из Google профиля (имя, email, фото). Юзеру НЕ нужно заполнять формы регистрации.
Не делать регистрацию по email/password. Только Google OAuth. Это упрощает систему и избавляет от необходимости верификации email, сброса пароля, и т.д.
Аватар берётся из Google profile photo URL и сохраняется в
avatar_url. Не скачивать файл — просто хранить URL.Стилизация кнопки Google — использовать встроенный компонент
<GoogleLogin />из@react-oauth/google. Он соответствует гайдлайнам Google и адаптивен. Не рисовать кастомную кнопку.
ТЕСТИРОВАНИЕ
- Открыть сайт → в хедере должна быть кнопка "Sign in with Google"
- Нажать → появляется Google popup → выбрать аккаунт
- После успешного входа → кнопка заменяется на аватар пользователя
- Нажать на аватар → dropdown с именем, email, ссылками на профиль/избранное, кнопка выхода
- Обновить страницу → пользователь остаётся залогиненным (JWT в cookie)
- Нажать "Выйти" → возвращается кнопка "Sign in with Google"
- Каталог, страницы композиций, композиторов — всё доступно без логина
ФАЙЛЫ КОТОРЫЕ НУЖНО СОЗДАТЬ/ИЗМЕНИТЬ
Создать:
backend/accounts/__init__.pybackend/accounts/models.pybackend/accounts/serializers.pybackend/accounts/views.pybackend/accounts/urls.pybackend/accounts/admin.pybackend/accounts/adapter.pybackend/accounts/apps.pyfrontend/src/context/AuthContext.tsxfrontend/src/components/AuthButton.tsx
Изменить:
backend/config/settings/base.py— добавить приложения, настройки allauth, JWT, CORSbackend/config/urls.py— добавить маршрут authbackend/requirements.txt— добавить пакетыfrontend/package.json— добавить зависимостиfrontend/src/app/layout.tsx— обернуть в провайдеры- Компонент Header — добавить
<AuthButton /> .env— добавить GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET