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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user