feat(auth): вход через OAuth-провайдеры (GitHub, Google, Discord)

Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.

Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.

Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.

Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
This commit is contained in:
2026-09-26 15:13:24 +03:00
parent cd1d662572
commit cebce0ae3b
23 changed files with 1721 additions and 12 deletions
@@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchOAuthProviders } from '@/api/oauth';
import { Card } from '@/components/ui/primitives';
/**
* Вход через внешние провайдеры (Фаза 7). Раздел только показывает, какие
* провайдеры включены на инстансе: привязка происходит при входе по
* подтверждённому email, а отвязка — действие оператора/администратора.
*/
export function LinkedAccountsSection() {
const { t } = useTranslation();
const providers = useQuery({
queryKey: ['auth', 'oauth', 'providers'],
queryFn: ({ signal }) => fetchOAuthProviders(signal),
staleTime: 5 * 60 * 1000,
});
const enabled = providers.data?.providers ?? [];
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.security.oauthTitle')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.security.oauthDescription')}</p>
{enabled.length === 0 ? (
<p className="mt-3 text-sm text-fg-muted" data-testid="oauth-none">
{t('settings.security.oauthDisabled')}
</p>
) : (
<ul className="mt-3 flex flex-col gap-2" data-testid="oauth-providers">
{enabled.map((provider) => (
<li
key={provider.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3 text-sm"
>
<span>{provider.name}</span>
<span className="text-xs text-fg-muted">
{t('settings.security.oauthLinkedByEmail')}
</span>
</li>
))}
</ul>
)}
</Card>
);
}