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
-19
View File
@@ -81,25 +81,6 @@ func newAPIError(err error) apiError {
case errors.Is(err, auth.ErrPasskeyLimit):
candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit"
candidate.Message = "passkey limit reached for this account"
// OAuth-провайдеры (Фаза 7).
case errors.Is(err, auth.ErrOAuthNotConfigured):
candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_not_configured"
candidate.Message = "oauth provider is not configured on this instance"
case errors.Is(err, auth.ErrOAuthUnknownProvider):
candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_unknown"
candidate.Message = "unknown oauth provider"
case errors.Is(err, auth.ErrOAuthState):
candidate.Status, candidate.Code = http.StatusBadRequest, "oauth.state_invalid"
candidate.Message = "oauth state is invalid or expired"
case errors.Is(err, auth.ErrOAuthEmailUnverified):
candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_unverified"
candidate.Message = "provider did not confirm the email address"
case errors.Is(err, auth.ErrOAuthEmailMissing):
candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_missing"
candidate.Message = "provider did not return an email address"
case errors.Is(err, auth.ErrOAuthExchange):
candidate.Status, candidate.Code = http.StatusBadGateway, "oauth.exchange_failed"
candidate.Message = "oauth provider rejected the request"
case errors.Is(err, store.ErrNotFound):
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
case errors.Is(err, store.ErrConflict):
-110
View File
@@ -1,110 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/httpx"
)
// registerOAuthRoutes вешает ручки входа через внешние провайдеры (AGENT.md 7.1).
// `start` и `callback` — публичные (это вход), лимит по IP общий с другими
// ручками аутентификации.
func (s *Server) registerOAuthRoutes(router chi.Router) {
oauthLimit := s.oauthLimiter.Middleware(func(r *http.Request) string {
return httpx.ClientIP(r, nil)
})
router.Get("/auth/oauth/providers", s.handleOAuthProviders)
router.With(oauthLimit).Get("/auth/oauth/{provider}/start", s.handleOAuthStart)
router.With(oauthLimit).Get("/auth/oauth/{provider}/callback", s.handleOAuthCallback)
}
// handleOAuthProviders отдаёт список включённых провайдеров: клиент рисует по
// нему кнопки входа, а закрытые провайдеры не показывает вовсе.
func (s *Server) handleOAuthProviders(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeJSON(w, map[string]any{"providers": []any{}, "redirect_url": ""})
return
}
writeJSON(w, map[string]any{
"providers": s.auth.OAuthProviders(),
"redirect_url": s.auth.OAuthRedirectURL(),
})
}
// handleOAuthStart уводит браузер на страницу согласия провайдера.
// Если провайдер не настроен — отвечаем понятной ошибкой, а не редиректом.
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
provider := chi.URLParam(r, "provider")
redirect := r.URL.Query().Get("redirect")
authorizeURL, err := s.auth.OAuthAuthorizeURL(provider, redirect)
if err != nil {
writeAPIError(w, err)
return
}
//nolint:gosec // адрес берётся из каталога провайдеров, пользователь его не задаёт
http.Redirect(w, r, authorizeURL, http.StatusFound)
}
// handleOAuthCallback принимает код, находит или создаёт аккаунт и возвращает
// пользователя в клиент. Ошибки уходят в `?oauth_error=<код>`: браузер видит
// понятное сообщение на странице входа, а не JSON.
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
provider := chi.URLParam(r, "provider")
query := r.URL.Query()
_, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider,
query.Get("code"), query.Get("state"), query,
httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context()))
if err != nil {
code := oauthErrorCode(err)
s.logger.InfoContext(r.Context(), "вход через OAuth не удался",
slog.String("provider", provider), slog.String("code", code))
http.Redirect(w, r, "/login?oauth_error="+url.QueryEscape(code), http.StatusFound)
return
}
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
// Признак в адресе нужен клиенту, чтобы перечитать профиль после возврата.
target := redirect
if target == "" || target == "/" {
target = "/app"
}
separator := "?"
if strings.Contains(target, "?") {
separator = "&"
}
//nolint:gosec // target — только внутренний путь: его проверил safeRedirect
http.Redirect(w, r, target+separator+"oauth=ok", http.StatusFound)
}
// oauthErrorCode переводит доменную ошибку в код для `?oauth_error=`.
func oauthErrorCode(err error) string {
for _, candidate := range []error{
auth.ErrOAuthNotConfigured,
auth.ErrOAuthUnknownProvider,
auth.ErrOAuthState,
auth.ErrOAuthEmailUnverified,
auth.ErrOAuthEmailMissing,
auth.ErrRegistrationOff,
auth.ErrUserBanned,
auth.ErrOAuthExchange,
} {
if errors.Is(err, candidate) {
return strings.TrimPrefix(candidate.Error(), "oauth.")
}
}
return "failed"
}
-92
View File
@@ -1,92 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает
// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7).
func TestOAuthProviderNotConfigured(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
}
// VK и Яндекс есть в каталоге, но без ключей тоже отвечают «не настроен».
for _, provider := range []string{"vk", "yandex"} {
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("%s start without config = %d, want 404", provider, rec.Code)
}
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
t.Fatalf("%s error code = %q, want oauth.provider_not_configured", provider, code)
}
}
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/ok/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown provider = %d, want 404", rec.Code)
}
if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" {
t.Fatalf("error code = %q, want oauth.provider_unknown", code)
}
// Список провайдеров пуст, клиент не рисует кнопки.
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "")
if rec.Code != http.StatusOK {
t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String())
}
var payload struct {
Providers []struct {
ID string `json:"id"`
} `json:"providers"`
RedirectURL string `json:"redirect_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode providers: %v", err)
}
if len(payload.Providers) != 0 {
t.Fatalf("providers = %s, want empty", rec.Body.String())
}
// Мета инстанса честно сообщает, что провайдеры выключены.
meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
if meta.Code != http.StatusOK {
t.Fatalf("meta = %d", meta.Code)
}
if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) {
t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String())
}
if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) {
t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String())
}
}
// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state
// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию.
func TestOAuthCallbackRejectsUnsignedState(t *testing.T) {
srv, _ := newTestServer(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusFound {
t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "/login?oauth_error=") {
t.Fatalf("callback must redirect to the login page with a code, got %q", location)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("failed oauth callback must not set a session cookie")
}
}
+1 -4
View File
@@ -71,10 +71,9 @@ type Server struct {
uploadDailyLimiter *httpx.RateLimiter
// reactionLimiter — 20 реакций в минуту на пользователя.
reactionLimiter *httpx.RateLimiter
// passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7):
// passkeyLimiter ограничивает ручки входа по ключу доступа (Фаза 7):
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
passkeyLimiter *httpx.RateLimiter
oauthLimiter *httpx.RateLimiter
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
pushLimiter *httpx.RateLimiter
@@ -147,7 +146,6 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
reactionLimiter: httpx.NewRateLimiter(20, 20),
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
// ответы лимит не тратят).
@@ -203,7 +201,6 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerAuthRoutes(apiRouter)
if deps.Auth != nil {
s.registerPasskeyRoutes(apiRouter)
s.registerOAuthRoutes(apiRouter)
}
if deps.Store != nil {
s.registerUserRoutes(s.api)