refactor: убрать вход через внешние провайдеры (OAuth) полностью
Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего входа только создаёт трение (регистрация приложений, redirect URI, модерация, чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа (WebAuthn). Удалено: - сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек; отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta; - клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры; - установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер чтения существующих значений переименован в existing_value — он остался нужен для VAPID_SUBJECT); - зависимость golang.org/x/oauth2 (go mod tidy). Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем — она применена на стендах). Откат миграции возвращает структуру; тест TestOAuthRemovalMigration проверяет накат, откат и повторный накат. AGENT.md (локальный) помечает пункты про OAuth как отменённые.
This commit is contained in:
@@ -5,7 +5,6 @@ export interface InstanceFeatures {
|
||||
anti_bot_enabled: boolean;
|
||||
voice_enabled: boolean;
|
||||
web_push_enabled: boolean;
|
||||
oauth_enabled: boolean;
|
||||
passkeys_enabled: boolean;
|
||||
/** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */
|
||||
unfurl_enabled: boolean;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -81,12 +81,6 @@
|
||||
"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.",
|
||||
@@ -194,14 +188,7 @@
|
||||
"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.",
|
||||
"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."
|
||||
"passkeyUnsupported": "This browser does not support passkeys. Sign in with your password."
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
@@ -900,11 +887,7 @@
|
||||
"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).",
|
||||
"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"
|
||||
"passkeysNameRequired": "Enter a key name (up to 64 characters)."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
|
||||
@@ -81,12 +81,6 @@
|
||||
"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": "Сервер не найден.",
|
||||
@@ -194,14 +188,7 @@
|
||||
"passkeySubmit": "Войти по ключу доступа",
|
||||
"passkeyPending": "Ждём ключ…",
|
||||
"passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.",
|
||||
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем.",
|
||||
"oauthHint": "Или войдите через внешний сервис:",
|
||||
"oauthSubmit": "Войти через {{provider}}",
|
||||
"oauthDone": "Вход выполнен. Завершаем…",
|
||||
"oauthFailed": "Вход через внешний сервис не удался. Попробуйте ещё раз или войдите с паролем.",
|
||||
"oauthNotConfigured": "Этот способ входа выключен на инстансе. Войдите с паролем.",
|
||||
"oauthEmailUnverified": "Внешний сервис не подтвердил вашу почту — вход невозможен.",
|
||||
"oauthEmailMissing": "Внешний сервис не отдал почту — войдите с паролем или обратитесь к администратору."
|
||||
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем."
|
||||
},
|
||||
"register": {
|
||||
"title": "Регистрация",
|
||||
@@ -900,11 +887,7 @@
|
||||
"passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.",
|
||||
"passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.",
|
||||
"passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.",
|
||||
"passkeysNameRequired": "Укажите название ключа (до 64 символов).",
|
||||
"oauthTitle": "Вход через внешние сервисы",
|
||||
"oauthDescription": "Провайдеры включаются администратором инстанса. Аккаунт привязывается автоматически при входе, если почта подтверждена провайдером.",
|
||||
"oauthDisabled": "На этом инстансе внешние провайдеры не настроены.",
|
||||
"oauthLinkedByEmail": "вход по подтверждённой почте"
|
||||
"passkeysNameRequired": "Укажите название ключа (до 64 символов)."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -46,13 +45,6 @@ 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({
|
||||
@@ -97,24 +89,8 @@ 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 =
|
||||
passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled'
|
||||
@@ -131,17 +107,6 @@ 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')}
|
||||
@@ -209,24 +174,6 @@ 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 ? (
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,7 +2,6 @@ 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';
|
||||
@@ -39,7 +38,6 @@ export default function SecuritySettingsPage() {
|
||||
}}
|
||||
/>
|
||||
<PasskeysSection twoFactorEnabled={twoFactorEnabled} />
|
||||
<LinkedAccountsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ const meta: InstanceMeta = {
|
||||
web_push_enabled: false,
|
||||
unfurl_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
oauth_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -310,13 +310,11 @@ const metaWithPasskeys = {
|
||||
voice_enabled: false,
|
||||
web_push_enabled: false,
|
||||
unfurl_enabled: false,
|
||||
oauth_enabled: true,
|
||||
passkeys_enabled: true,
|
||||
oauth_providers: ['github'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('страница входа: ключи и внешние провайдеры', () => {
|
||||
describe('страница входа: ключи доступа', () => {
|
||||
it('входит по ключу и переводит в приложение', async () => {
|
||||
const assertion = {
|
||||
id: 'credential-id',
|
||||
@@ -359,11 +357,6 @@ describe('страница входа: ключи и внешние провай
|
||||
method: 'POST',
|
||||
response: () => json({ user }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/auth/oauth/providers',
|
||||
response: () =>
|
||||
json({ providers: [{ id: 'github', name: 'GitHub' }], redirect_url: 'https://x' }),
|
||||
},
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
]);
|
||||
@@ -371,8 +364,6 @@ describe('страница входа: ключи и внешние провай
|
||||
const { router } = renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
// Кнопка внешнего провайдера нарисована по списку с сервера.
|
||||
expect(await screen.findByTestId('oauth-github')).toBeVisible();
|
||||
await visitor.click(await screen.findByTestId('passkey-login'));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -382,39 +373,11 @@ describe('страница входа: ключи и внешние провай
|
||||
expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login');
|
||||
});
|
||||
|
||||
it('ошибка возврата от провайдера показывается на странице входа', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: '/api/v1/meta',
|
||||
response: () =>
|
||||
json({
|
||||
...metaWithPasskeys,
|
||||
features: { ...metaWithPasskeys.features, passkeys_enabled: false },
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{
|
||||
match: '/api/v1/auth/oauth/providers',
|
||||
response: () => json({ providers: [], redirect_url: 'https://x' }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/login?oauth_error=provider_not_configured');
|
||||
|
||||
expect(await screen.findByTestId('oauth-error')).toHaveTextContent(
|
||||
'Этот способ входа выключен на инстансе',
|
||||
);
|
||||
});
|
||||
|
||||
it('без поддержки браузера кнопка входа по ключу не показывается', async () => {
|
||||
vi.stubGlobal('PublicKeyCredential', undefined);
|
||||
installFetch([
|
||||
{ match: '/api/v1/meta', response: () => json(metaWithPasskeys) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{
|
||||
match: '/api/v1/auth/oauth/providers',
|
||||
response: () => json({ providers: [], redirect_url: 'https://x' }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/login');
|
||||
|
||||
@@ -78,7 +78,6 @@ const enabledMeta: InstanceMeta = {
|
||||
web_push_enabled: false,
|
||||
unfurl_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
oauth_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user