Привязка аккаунтов социальных сетей
Привязка позволяет пользователю с email/password аккаунтом добавить Google или GitHub как способ входа — и наоборот. Главная сложность: безопасно идентифицировать владельца перед привязкой.
Архитектура: схема данных
model User {
id String @id @default(cuid())
email String @unique
passwordHash String? // null если только соцсети
accounts LinkedAccount[]
}
model LinkedAccount {
id String @id @default(cuid())
userId String
provider String // 'google' | 'github' | 'apple'
providerAccountId String // ID в системе провайдера
providerEmail String? // email от провайдера
linkedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
Server Actions: привязка провайдера
// app/settings/security/actions.ts
'use server';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { generateState } from '@/lib/oauth';
export async function initiateLinkProvider(provider: string) {
const session = await auth();
if (!session) redirect('/login');
// Генерируем state с userId (защита от CSRF)
const state = await generateState({
userId: session.user.id,
action: 'link',
provider,
});
// Перенаправляем на OAuth flow с параметром link=true
redirect(`/api/auth/link/${provider}?state=${state}`);
}
export async function unlinkProvider(accountId: string) {
const session = await auth();
if (!session) redirect('/login');
// Проверяем, что это аккаунт текущего пользователя
const account = await db.linkedAccount.findFirst({
where: { id: accountId, userId: session.user.id }
});
if (!account) {
throw new Error('Account not found');
}
// Нельзя отвязать единственный способ входа
const accountsCount = await db.linkedAccount.count({
where: { userId: session.user.id }
});
const hasPassword = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true }
});
if (accountsCount <= 1 && !hasPassword?.passwordHash) {
throw new Error('Cannot unlink the only login method');
}
await db.linkedAccount.delete({ where: { id: accountId } });
}
OAuth Callback: обработка привязки
// app/api/auth/callback/[provider]/route.ts
export async function GET(
request: Request,
{ params }: { params: { provider: string } }
) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
// Расшифровываем state
const stateData = await verifyState(state);
if (!stateData || stateData.action !== 'link') {
redirect('/settings/security?error=invalid_state');
}
// Обмениваем code на access token
const tokens = await exchangeCode(params.provider, code);
const providerUser = await fetchProviderUser(params.provider, tokens.accessToken);
// Проверяем: не привязан ли уже этот аккаунт к другому пользователю
const existingLink = await db.linkedAccount.findUnique({
where: {
provider_providerAccountId: {
provider: params.provider,
providerAccountId: providerUser.id,
}
}
});
if (existingLink && existingLink.userId !== stateData.userId) {
redirect('/settings/security?error=account_already_linked');
}
if (!existingLink) {
await db.linkedAccount.create({
data: {
userId: stateData.userId,
provider: params.provider,
providerAccountId: providerUser.id,
providerEmail: providerUser.email,
}
});
}
redirect('/settings/security?success=linked');
}
UI компонент управления
// components/LinkedAccountsManager.tsx
'use client';
import { useState, useTransition } from 'react';
import { initiateLinkProvider, unlinkProvider } from './actions';
const PROVIDERS = [
{ id: 'google', name: 'Google', icon: <GoogleIcon /> },
{ id: 'github', name: 'GitHub', icon: <GitHubIcon /> },
{ id: 'apple', name: 'Apple', icon: <AppleIcon /> },
];
export function LinkedAccountsManager({
linkedAccounts,
hasPassword,
}: {
linkedAccounts: Array<{ id: string; provider: string; providerEmail: string | null }>;
hasPassword: boolean;
}) {
const [isPending, startTransition] = useTransition();
const linkedProviders = new Set(linkedAccounts.map(a => a.provider));
const canUnlink = (provider: string) => {
// Нельзя отвязать если это единственный способ входа
const otherAccounts = linkedAccounts.filter(a => a.provider !== provider);
return hasPassword || otherAccounts.length > 0;
};
return (
<div className="space-y-4">
{PROVIDERS.map((provider) => {
const linked = linkedAccounts.find(a => a.provider === provider.id);
return (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
{provider.icon}
<div>
<p className="font-medium">{provider.name}</p>
{linked && (
<p className="text-sm text-gray-500">{linked.providerEmail}</p>
)}
</div>
</div>
{linked ? (
<button
onClick={() => startTransition(() => unlinkProvider(linked.id))}
disabled={!canUnlink(provider.id) || isPending}
className="text-red-600 disabled:opacity-50"
>
Отвязать
</button>
) : (
<button
onClick={() => startTransition(() => initiateLinkProvider(provider.id))}
disabled={isPending}
className="text-blue-600"
>
Привязать
</button>
)}
</div>
);
})}
</div>
);
}
Реализация привязки/отвязки OAuth провайдеров с защитой от блокировки аккаунта — 2–3 рабочих дня.







