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:
2026-09-27 18:27:33 +03:00
parent c88f774749
commit a48b865bc9
27 changed files with 100 additions and 2287 deletions
-95
View File
@@ -1,95 +0,0 @@
package store
import (
"context"
"database/sql"
"time"
)
// OAuthAccount — связь внешнего провайдера с пользователем инстанса.
// Сырого идентификатора и email здесь нет: только blind index (AGENT.md 9.2).
type OAuthAccount struct {
ID uint64
UserID uint64
Provider string
SubjectIndex string
EmailIndex string
CreatedAt time.Time
LastLoginAt *time.Time
}
// LinkOAuthAccount привязывает внешний аккаунт к пользователю. Повторная
// привязка того же провайдера к другому пользователю отклоняется уникальным
// индексом (provider, subject_index) — это защита от подмены владельца.
func (s *Store) LinkOAuthAccount(ctx context.Context, userID uint64, provider, subjectIndex, emailIndex string) (*OAuthAccount, error) {
id := s.NextID()
_, err := s.writer.ExecContext(ctx, `
INSERT INTO oauth_accounts (id, user_id, provider, subject_index, email_index, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
int64(id), int64(userID), provider, subjectIndex, emailIndex, s.Now())
if err != nil {
return nil, mapError(err)
}
return s.GetOAuthAccount(ctx, id)
}
func (s *Store) GetOAuthAccount(ctx context.Context, id uint64) (*OAuthAccount, error) {
row := s.reader.QueryRowContext(ctx, `
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
FROM oauth_accounts WHERE id = ?`, int64(id))
return scanOAuthAccount(row)
}
// OAuthAccountBySubject ищет привязку по blind index внешнего идентификатора.
func (s *Store) OAuthAccountBySubject(ctx context.Context, provider, subjectIndex string) (*OAuthAccount, error) {
row := s.reader.QueryRowContext(ctx, `
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
FROM oauth_accounts WHERE provider = ? AND subject_index = ?`, provider, subjectIndex)
return scanOAuthAccount(row)
}
// ListOAuthAccounts возвращает привязки пользователя: показываются в настройках
// безопасности, чтобы входы через провайдеров не были невидимыми.
func (s *Store) ListOAuthAccounts(ctx context.Context, userID uint64) ([]OAuthAccount, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
FROM oauth_accounts WHERE user_id = ? ORDER BY created_at`, int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
accounts := make([]OAuthAccount, 0, 2)
for rows.Next() {
account, err := scanOAuthAccount(rows)
if err != nil {
return nil, err
}
accounts = append(accounts, *account)
}
return accounts, rows.Err()
}
// TouchOAuthAccount отмечает вход через провайдера.
func (s *Store) TouchOAuthAccount(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx, `UPDATE oauth_accounts SET last_login_at = ? WHERE id = ?`, s.Now(), int64(id))
return err
}
func scanOAuthAccount(scanner interface{ Scan(...any) error }) (*OAuthAccount, error) {
var (
account OAuthAccount
createdAt string
lastLoginAt sql.NullString
)
if err := scanner.Scan(&account.ID, &account.UserID, &account.Provider, &account.SubjectIndex,
&account.EmailIndex, &createdAt, &lastLoginAt); err != nil {
return nil, mapError(err)
}
account.CreatedAt = parseTimestamp(createdAt)
if lastLoginAt.Valid {
value := parseTimestamp(lastLoginAt.String)
account.LastLoginAt = &value
}
return &account, nil
}