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
+41
View File
@@ -0,0 +1,41 @@
import { request } from './client';
/** Провайдер входа, включённый на инстансе (AGENT.md 7.1, Фаза 7). */
export interface OAuthProvider {
id: string;
name: string;
}
export interface OAuthProvidersResponse {
providers: OAuthProvider[];
/** Адрес возврата: его оператор прописывает в настройках приложения. */
redirect_url: string;
}
export function fetchOAuthProviders(signal?: AbortSignal): Promise<OAuthProvidersResponse> {
return request<OAuthProvidersResponse>(
'/auth/oauth/providers',
signal === undefined ? {} : { signal },
);
}
/**
* Ссылка входа через провайдера. Браузер уходит по ней целиком (обычная
* навигация): cookie сессии выставляет сервер после возврата.
*/
export function oauthStartURL(provider: string, redirect?: string): string {
const query = new URLSearchParams();
if (redirect !== undefined && redirect !== '') {
query.set('redirect', redirect);
}
const suffix = query.toString();
return `/api/v1/auth/oauth/${encodeURIComponent(provider)}/start${suffix === '' ? '' : `?${suffix}`}`;
}
/** Код ошибки возврата (`?oauth_error=`), если вход через провайдера не удался. */
export function oauthErrorCode(value: string | null): string | null {
if (value === null || value === '') {
return null;
}
return value;
}
+19 -2
View File
@@ -81,6 +81,12 @@
"auth.passkey_unknown": "This key is not registered on the account.",
"auth.passkey_name_invalid": "Enter a key name (up to 64 characters).",
"auth.passkey_limit": "The account reached its passkey limit.",
"oauth.provider_not_configured": "The provider is not configured on this instance.",
"oauth.provider_unknown": "Unknown sign-in provider.",
"oauth.state_invalid": "The sign-in link expired. Start again.",
"oauth.email_unverified": "The provider did not confirm the email — sign-in is impossible.",
"oauth.email_missing": "The provider returned no email — sign-in is impossible.",
"oauth.exchange_failed": "The provider rejected the request. Try later.",
"permissions.denied": "You do not have permission for this action.",
"instance.admin_required": "This action is available to the instance admin only.",
"guild.not_found": "Server not found.",
@@ -176,7 +182,14 @@
"passkeySubmit": "Sign in with a passkey",
"passkeyPending": "Waiting for the key…",
"passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.",
"passkeyUnsupported": "This browser does not support passkeys. Sign in with your password."
"passkeyUnsupported": "This browser does not support passkeys. Sign in with your password.",
"oauthHint": "Or sign in with an external service:",
"oauthSubmit": "Sign in with {{provider}}",
"oauthDone": "Signed in. Finishing…",
"oauthFailed": "External sign-in failed. Try again or use your password.",
"oauthNotConfigured": "This sign-in method is disabled on the instance. Use your password.",
"oauthEmailUnverified": "The provider did not confirm your email — sign-in is impossible.",
"oauthEmailMissing": "The provider returned no email — use your password or contact the administrator."
},
"register": {
"title": "Create account",
@@ -828,7 +841,11 @@
"passkeysTimeout": "The authenticator did not respond in time. Try again.",
"passkeysAlreadyRegistered": "This key is already registered on the account.",
"passkeysFailed": "Could not create the key. Try another authenticator.",
"passkeysNameRequired": "Enter a key name (up to 64 characters)."
"passkeysNameRequired": "Enter a key name (up to 64 characters).",
"oauthTitle": "External sign-in providers",
"oauthDescription": "Providers are enabled by the instance administrator. An account is linked automatically on sign-in when the provider confirms the email.",
"oauthDisabled": "No external providers are configured on this instance.",
"oauthLinkedByEmail": "sign-in via confirmed email"
},
"appearance": {
"title": "Appearance",
+19 -2
View File
@@ -81,6 +81,12 @@
"auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.",
"auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).",
"auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.",
"oauth.provider_not_configured": "Провайдер не настроен на инстансе.",
"oauth.provider_unknown": "Неизвестный провайдер входа.",
"oauth.state_invalid": "Ссылка входа устарела. Начните вход заново.",
"oauth.email_unverified": "Провайдер не подтвердил почту — вход невозможен.",
"oauth.email_missing": "Провайдер не отдал почту — вход невозможен.",
"oauth.exchange_failed": "Провайдер отклонил запрос. Попробуйте позже.",
"permissions.denied": "Недостаточно прав для этого действия.",
"instance.admin_required": "Действие доступно только администратору инстанса.",
"guild.not_found": "Сервер не найден.",
@@ -176,7 +182,14 @@
"passkeySubmit": "Войти по ключу доступа",
"passkeyPending": "Ждём ключ…",
"passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.",
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем."
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем.",
"oauthHint": "Или войдите через внешний сервис:",
"oauthSubmit": "Войти через {{provider}}",
"oauthDone": "Вход выполнен. Завершаем…",
"oauthFailed": "Вход через внешний сервис не удался. Попробуйте ещё раз или войдите с паролем.",
"oauthNotConfigured": "Этот способ входа выключен на инстансе. Войдите с паролем.",
"oauthEmailUnverified": "Внешний сервис не подтвердил вашу почту — вход невозможен.",
"oauthEmailMissing": "Внешний сервис не отдал почту — войдите с паролем или обратитесь к администратору."
},
"register": {
"title": "Регистрация",
@@ -828,7 +841,11 @@
"passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.",
"passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.",
"passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.",
"passkeysNameRequired": "Укажите название ключа (до 64 символов)."
"passkeysNameRequired": "Укажите название ключа (до 64 символов).",
"oauthTitle": "Вход через внешние сервисы",
"oauthDescription": "Провайдеры включаются администратором инстанса. Аккаунт привязывается автоматически при входе, если почта подтверждена провайдером.",
"oauthDisabled": "На этом инстансе внешние провайдеры не настроены.",
"oauthLinkedByEmail": "вход по подтверждённой почте"
},
"appearance": {
"title": "Внешний вид",
+56 -1
View File
@@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { login } from '@/api/auth';
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { fetchOAuthProviders, oauthStartURL } from '@/api/oauth';
import { loginWithPasskey } from '@/api/passkeys';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
@@ -24,7 +25,8 @@ interface LocationState {
* приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке
* входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код.
*
* Дополнительно (Фаза 7): вход по ключу доступа (passkeys).
* Дополнительно (Фаза 7): вход по ключу доступа (passkeys) и кнопки внешних
* провайдеров, включённых на инстансе.
*/
export default function LoginPage() {
const { t } = useTranslation();
@@ -44,6 +46,13 @@ export default function LoginPage() {
const stateFrom = (location.state as LocationState | null)?.from ?? null;
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
const oauthReturned = searchParams.get('oauth');
const oauthError = searchParams.get('oauth_error');
const providers = useQuery({
queryKey: ['auth', 'oauth', 'providers'],
queryFn: ({ signal }) => fetchOAuthProviders(signal),
staleTime: 5 * 60 * 1000,
});
// Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд
// без домена) WebAuthn невозможен, и кнопку показывать нельзя.
const meta = useQuery({
@@ -88,6 +97,23 @@ export default function LoginPage() {
};
const registrationEnabled = instance.data?.registration_enabled === true;
const providerList = providers.data?.providers ?? [];
// Ошибка возврата от провайдера: показываем человеческий текст.
const oauthErrorMessage =
oauthError === null
? null
: t(
oauthError === 'provider_not_configured'
? 'auth.login.oauthNotConfigured'
: oauthError === 'email_unverified'
? 'auth.login.oauthEmailUnverified'
: oauthError === 'email_missing'
? 'auth.login.oauthEmailMissing'
: oauthError === 'user.banned'
? 'errors.user.banned'
: 'auth.login.oauthFailed',
);
// Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать.
const passkeyErrorKey =
@@ -105,6 +131,17 @@ export default function LoginPage() {
</header>
<Card>
{oauthReturned === 'ok' ? (
<p className="mb-4 text-sm text-success" role="status">
{t('auth.login.oauthDone')}
</p>
) : null}
{oauthErrorMessage === null ? null : (
<p className="mb-4 text-sm text-danger" role="alert" data-testid="oauth-error">
{oauthErrorMessage}
</p>
)}
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('auth.login.email')}
@@ -172,6 +209,24 @@ export default function LoginPage() {
</div>
) : null}
{providerList.length === 0 ? null : (
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-4">
<p className="text-xs text-fg-muted">{t('auth.login.oauthHint')}</p>
{providerList.map((provider) => (
<Button
key={provider.id}
variant="ghost"
data-testid={`oauth-${provider.id}`}
onClick={() => {
window.location.assign(oauthStartURL(provider.id, from));
}}
>
{t('auth.login.oauthSubmit', { provider: provider.name })}
</Button>
))}
</div>
)}
<p className="mt-4 text-sm text-fg-muted">
{t('auth.login.noAccount')}{' '}
{registrationEnabled ? (
@@ -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>
);
}
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
import { LinkedAccountsSection } from '@/pages/settings/LinkedAccountsSection';
import { PasskeysSection } from '@/pages/settings/PasskeysSection';
import { PasswordSection } from '@/pages/settings/PasswordSection';
import { SessionsSection } from '@/pages/settings/SessionsSection';
@@ -9,7 +10,8 @@ import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
/**
* Безопасность: смена пароля со step-up, список сессий с выходом на всех
* устройствах, включение 2FA и ключи доступа (passkeys, Фаза 7).
* устройствах, включение 2FA, ключи доступа (passkeys) и вход через внешние
* провайдеры (Фаза 7).
*
* Состояние 2FA берём из профиля (`totp_enabled`), обновляя запрос после
* включения: от него зависит, требовать ли код при step-up.
@@ -37,6 +39,7 @@ export default function SecuritySettingsPage() {
}}
/>
<PasskeysSection twoFactorEnabled={twoFactorEnabled} />
<LinkedAccountsSection />
</div>
);
}