diff --git a/.env.example b/.env.example index 36b1b39..f644acd 100644 --- a/.env.example +++ b/.env.example @@ -38,23 +38,6 @@ TOTP_ENCRYPTION_KEY= # шифрование TOTP-секрет WEBHOOK_SECRET= # подпись вебхуков LIVEKIT_API_KEY= # ключ LiveKit (фаза 3) LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3) -# --- OAuth-провайдеры (Фаза 7): вход через внешние сервисы --- -# Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured. -# Адрес возврата для настроек приложения у провайдера: -# <домен>/api/v1/auth/oauth//callback -OAUTH_VK_CLIENT_ID= -OAUTH_VK_CLIENT_SECRET= -OAUTH_YANDEX_CLIENT_ID= -OAUTH_YANDEX_CLIENT_SECRET= -OAUTH_GITHUB_CLIENT_ID= -OAUTH_GITHUB_CLIENT_SECRET= -OAUTH_GOOGLE_CLIENT_ID= -OAUTH_GOOGLE_CLIENT_SECRET= -OAUTH_DISCORD_CLIENT_ID= -OAUTH_DISCORD_CLIENT_SECRET= -# Необязательно: внешний адрес инстанса для redirect_uri (пусто — берётся из DOMAIN). -OAUTH_REDIRECT_BASE= - # --- Web Push (Фаза 7): уведомления в браузере и на телефоне --- # Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто — # Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`. diff --git a/deploy/install.sh b/deploy/install.sh index 28a28c5..67fce85 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -718,9 +718,10 @@ generate_secrets() { # расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index, # LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие # данные нечитаемыми, поэтому она возможна только явным --rotate-secrets. - # oauth_value отдаёт значение OAuth-настройки: сначала из окружения - # (оператор задал при запуске), затем из существующего .env. - oauth_value() { + # existing_value отдаёт значение необязательной настройки: сначала из + # окружения (оператор задал при запуске), затем из существующего .env — + # чтобы `--reconfigure` её не потерял. + existing_value() { local key="$1" local value="${!key:-}" if [ -z "$value" ] && [ "$env_existing" = "1" ]; then @@ -774,7 +775,7 @@ generate_secrets() { fi # Контакт администратора для push-сервисов: значение можно задать заранее, # иначе берём email администратора или адрес домена. - VAPID_SUBJECT="$(oauth_value VAPID_SUBJECT)" + VAPID_SUBJECT="$(existing_value VAPID_SUBJECT)" if [ -z "$VAPID_SUBJECT" ]; then if [ -n "${ADMIN_EMAIL:-}" ]; then VAPID_SUBJECT="mailto:${ADMIN_EMAIL}" @@ -783,21 +784,6 @@ generate_secrets() { fi fi - # OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер. - # Значение берём из окружения, иначе сохраняем прежнее из .env — иначе - # `--reconfigure` молча выключил бы вход через провайдеров. - OAUTH_VK_CLIENT_ID="$(oauth_value OAUTH_VK_CLIENT_ID)" - OAUTH_VK_CLIENT_SECRET="$(oauth_value OAUTH_VK_CLIENT_SECRET)" - OAUTH_YANDEX_CLIENT_ID="$(oauth_value OAUTH_YANDEX_CLIENT_ID)" - OAUTH_YANDEX_CLIENT_SECRET="$(oauth_value OAUTH_YANDEX_CLIENT_SECRET)" - OAUTH_GITHUB_CLIENT_ID="$(oauth_value OAUTH_GITHUB_CLIENT_ID)" - OAUTH_GITHUB_CLIENT_SECRET="$(oauth_value OAUTH_GITHUB_CLIENT_SECRET)" - OAUTH_GOOGLE_CLIENT_ID="$(oauth_value OAUTH_GOOGLE_CLIENT_ID)" - OAUTH_GOOGLE_CLIENT_SECRET="$(oauth_value OAUTH_GOOGLE_CLIENT_SECRET)" - OAUTH_DISCORD_CLIENT_ID="$(oauth_value OAUTH_DISCORD_CLIENT_ID)" - OAUTH_DISCORD_CLIENT_SECRET="$(oauth_value OAUTH_DISCORD_CLIENT_SECRET)" - OAUTH_REDIRECT_BASE="$(oauth_value OAUTH_REDIRECT_BASE)" - if [ "$DRY_RUN" = "1" ]; then log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)" ok "секреты подготовлены (dry-run)" @@ -972,24 +958,6 @@ BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@ BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@ AGE_RECIPIENT=@AGE_RECIPIENT@ -# --- OAuth-провайдеры (Фаза 7) --- -# Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth//callback. -# VK ID: id.vk.ru → «Мои приложения»; секрет — сервисный ключ доступа -# (для конфиденциального приложения он обязателен при обмене кода на токен). -OAUTH_VK_CLIENT_ID=@OAUTH_VK_CLIENT_ID@ -OAUTH_VK_CLIENT_SECRET=@OAUTH_VK_CLIENT_SECRET@ -# Яндекс ID: oauth.yandex.ru → «Создать приложение» (права login:email, login:info). -OAUTH_YANDEX_CLIENT_ID=@OAUTH_YANDEX_CLIENT_ID@ -OAUTH_YANDEX_CLIENT_SECRET=@OAUTH_YANDEX_CLIENT_SECRET@ -OAUTH_GITHUB_CLIENT_ID=@OAUTH_GITHUB_CLIENT_ID@ -OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@ -OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@ -OAUTH_GOOGLE_CLIENT_SECRET=@OAUTH_GOOGLE_CLIENT_SECRET@ -OAUTH_DISCORD_CLIENT_ID=@OAUTH_DISCORD_CLIENT_ID@ -OAUTH_DISCORD_CLIENT_SECRET=@OAUTH_DISCORD_CLIENT_SECRET@ -# Необязательно: внешний адрес инстанса для redirect_uri (пусто — из домена). -OAUTH_REDIRECT_BASE=@OAUTH_REDIRECT_BASE@ - # --- Web Push (Фаза 7) --- # Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен. VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@ @@ -1066,21 +1034,6 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY} LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL} -# OAuth-провайдеры (Фаза 7): пусто — провайдер выключен. Кавычки добавляет -# установщик только для значений со спецсимволами (docker compose разбирает -# .env своим парсером и пустые кавычки ему не нравятся). -OAUTH_VK_CLIENT_ID=$(env_quote "$OAUTH_VK_CLIENT_ID") -OAUTH_VK_CLIENT_SECRET=$(env_quote "$OAUTH_VK_CLIENT_SECRET") -OAUTH_YANDEX_CLIENT_ID=$(env_quote "$OAUTH_YANDEX_CLIENT_ID") -OAUTH_YANDEX_CLIENT_SECRET=$(env_quote "$OAUTH_YANDEX_CLIENT_SECRET") -OAUTH_GITHUB_CLIENT_ID=$(env_quote "$OAUTH_GITHUB_CLIENT_ID") -OAUTH_GITHUB_CLIENT_SECRET=$(env_quote "$OAUTH_GITHUB_CLIENT_SECRET") -OAUTH_GOOGLE_CLIENT_ID=$(env_quote "$OAUTH_GOOGLE_CLIENT_ID") -OAUTH_GOOGLE_CLIENT_SECRET=$(env_quote "$OAUTH_GOOGLE_CLIENT_SECRET") -OAUTH_DISCORD_CLIENT_ID=$(env_quote "$OAUTH_DISCORD_CLIENT_ID") -OAUTH_DISCORD_CLIENT_SECRET=$(env_quote "$OAUTH_DISCORD_CLIENT_SECRET") -OAUTH_REDIRECT_BASE=$(env_quote "$OAUTH_REDIRECT_BASE") - # Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке. VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT") diff --git a/go.mod b/go.mod index d1cf261..eac7403 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module glchat go 1.26.0 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coder/websocket v1.8.15 github.com/danielgtaylor/huma/v2 v2.39.1 github.com/fxamacker/cbor/v2 v2.9.4 @@ -12,11 +13,10 @@ require ( github.com/pquerna/otp v1.5.0 github.com/pressly/goose/v3 v3.28.0 golang.org/x/crypto v0.57.0 - golang.org/x/oauth2 v0.37.0 + golang.org/x/net v0.59.0 ) require ( - github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.3.1 // indirect @@ -29,7 +29,6 @@ require ( github.com/tinylib/msgp v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index a3990db..0712eb5 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,6 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= -golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go deleted file mode 100644 index 88567b4..0000000 --- a/internal/auth/oauth.go +++ /dev/null @@ -1,877 +0,0 @@ -package auth - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "golang.org/x/oauth2" - - "glchat/internal/config" - "glchat/internal/crypto" - "glchat/internal/store" -) - -// OAuth-вход (AGENT.md 7.1): провайдеры включаются переменными окружения, -// привязка внешнего аккаунта идёт по подтверждённому email через blind index. -// Токены провайдера не сохраняются: они нужны только на время обмена кода. -const ( - // oauthStateTTL — срок жизни подписанного state (защита от CSRF). - oauthStateTTL = 10 * time.Minute - // oauthHTTPTimeout ограничивает запросы к провайдеру. - oauthHTTPTimeout = 10 * time.Second -) - -var ( - // ErrOAuthNotConfigured — провайдер выключен (нет client id/secret). - ErrOAuthNotConfigured = errors.New("oauth.provider_not_configured") - // ErrOAuthUnknownProvider — неизвестное имя провайдера. - ErrOAuthUnknownProvider = errors.New("oauth.provider_unknown") - // ErrOAuthState — state не прошёл проверку подписи или истёк. - ErrOAuthState = errors.New("oauth.state_invalid") - // ErrOAuthExchange — обмен кода или запрос профиля не удался. - ErrOAuthExchange = errors.New("oauth.exchange_failed") - // ErrOAuthEmailUnverified — провайдер не подтвердил email. - ErrOAuthEmailUnverified = errors.New("oauth.email_unverified") - // ErrOAuthEmailMissing — провайдер не отдал email. - ErrOAuthEmailMissing = errors.New("oauth.email_missing") -) - -// oauthProvider — описание провайдера: всё, что нужно для входа. -type oauthProvider struct { - ID string - Name string - Endpoint oauth2.Endpoint - Scopes []string - // PKCE включает code_challenge/code_verifier (RFC 7636). Требуется VK ID: - // верификатор выводится из подписанного state, хранить его не нужно. - PKCE bool - // DeviceIDParam — имя query-параметра, который провайдер возвращает вместе с - // кодом и требует при обмене кода на токен (у VK ID это device_id). - DeviceIDParam string - // SecretOptional — провайдер работает и без секрета (публичное приложение - // VK ID: обмен защищён PKCE, сервисный ключ нужен только конфиденциальным). - SecretOptional bool - // TokenSecretParam — имя POST-параметра для секрета при обмене кода. - // Пусто — секрет уходит обычной Basic-авторизацией (как у GitHub/Google). - // У VK ID это service_token: он нужен конфиденциальным приложениям, а - // client_secret в запросе не передаётся вовсе. - TokenSecretParam string - // FetchProfile получает профиль по токену: email, признак подтверждения, - // внешний идентификатор и логин. clientID нужен провайдерам, которые - // запрашивают данные пользователя вместе с идентификатором приложения (VK ID). - FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token, clientID string) (oauthProfile, error) -} - -// oauthProfile — нормализованный профиль внешнего аккаунта. -type oauthProfile struct { - Subject string - Email string - EmailVerified bool - Login string - // DisplayName — имя из профиля провайдера (VK ID и Яндекс отдают его - // отдельно от логина). Пусто — берётся Login. - DisplayName string -} - -// OAuthProviderInfo — описание включённого провайдера для клиента. -type OAuthProviderInfo struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// oauthProviderSet — каталог провайдеров. Переменная, а не функция, чтобы -// тесты подменяли адреса провайдеров на httptest-сервер (в бою — значения по -// умолчанию, см. defaultOAuthProviders). -var oauthProviderSet = defaultOAuthProviders() - -// oauthProviders возвращает каталог поддерживаемых провайдеров (AGENT.md 7.1: -// Google, GitHub, Discord). Новый провайдер добавляется одной записью. -func oauthProviders() map[string]oauthProvider { return oauthProviderSet } - -//nolint:gosec // G101: здесь публичные адреса провайдеров, а не учётные данные -func defaultOAuthProviders() map[string]oauthProvider { - return map[string]oauthProvider{ - "vk": { - ID: "vk", - Name: "VK ID", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://id.vk.ru/authorize", - // Обмен кода: POST с параметрами в теле (client_id, code_verifier, - // device_id, state, service_token) — Basic-авторизацию VK не ждёт. - TokenURL: "https://id.vk.ru/oauth2/auth", - AuthStyle: oauth2.AuthStyleInParams, - }, - Scopes: []string{"email", "vkid.personal_info"}, - // VK ID без PKCE не работает: code_challenge обязателен в запросе - // авторизации, device_id возвращается в callback и нужен при обмене. - PKCE: true, - SecretOptional: true, - DeviceIDParam: "device_id", - TokenSecretParam: "service_token", - FetchProfile: fetchVKProfile, - }, - "yandex": { - ID: "yandex", - Name: "Яндекс", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://oauth.yandex.ru/authorize", - TokenURL: "https://oauth.yandex.ru/token", - // Яндекс принимает и Basic, и параметры в теле; выбираем тело — - // так поведение не зависит от того, как прокси переписывает - // заголовок Authorization. - AuthStyle: oauth2.AuthStyleInParams, - }, - Scopes: []string{"login:email", "login:info"}, - FetchProfile: fetchYandexProfile, - }, - "github": { - ID: "github", - Name: "GitHub", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://github.com/login/oauth/authorize", - TokenURL: "https://github.com/login/oauth/access_token", - }, - Scopes: []string{"read:user", "user:email"}, - FetchProfile: fetchGitHubProfile, - }, - "google": { - ID: "google", - Name: "Google", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", - TokenURL: "https://oauth2.googleapis.com/token", - }, - Scopes: []string{"openid", "email", "profile"}, - FetchProfile: fetchGoogleProfile, - }, - "discord": { - ID: "discord", - Name: "Discord", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://discord.com/oauth2/authorize", - TokenURL: "https://discord.com/api/oauth2/token", - }, - Scopes: []string{"identify", "email"}, - FetchProfile: fetchDiscordProfile, - }, - } -} - -// oauthConfigs — включённые провайдеры: id → параметры клиента. -func (s *Service) oauthConfigs() map[string]oauthProvider { - providers := oauthProviders() - enabled := make(map[string]oauthProvider, len(providers)) - for id, provider := range providers { - credentials, ok := s.oauth[id] - if !ok || credentials.clientID == "" { - continue - } - if credentials.clientSecret == "" && !provider.SecretOptional { - continue - } - provider.Scopes = append([]string{}, provider.Scopes...) - enabled[id] = provider - if id == "google" { - // Google требует offline-доступ только при необходимости refresh; - // нам достаточно одноразового обмена кода. - enabled[id] = provider - } - } - return enabled -} - -// OAuthProviders перечисляет включённые провайдеры для клиента. -func (s *Service) OAuthProviders() []OAuthProviderInfo { - configs := s.oauthConfigs() - // Порядок стабилен, чтобы клиент не переставлял кнопки между запросами. - // VK ID и Яндекс — первыми: инстанс рассчитан на русскоязычных пользователей. - order := []string{"vk", "yandex", "github", "google", "discord"} - infos := make([]OAuthProviderInfo, 0, len(configs)) - for _, id := range order { - if provider, ok := configs[id]; ok { - infos = append(infos, OAuthProviderInfo{ID: provider.ID, Name: provider.Name}) - } - } - return infos -} - -// OAuthEnabled сообщает, настроен ли хотя бы один провайдер. -func (s *Service) OAuthEnabled() bool { return len(s.oauthConfigs()) > 0 } - -// oauthClientCredentials — client id/secret конкретного провайдера. -type oauthClientCredentials struct { - clientID string - clientSecret string -} - -// OAuthAuthorizeURL строит ссылку на провайдера с подписанным state. -// redirect — куда вернуть пользователя внутри клиента после входа. -func (s *Service) OAuthAuthorizeURL(providerID, redirect string) (string, error) { - configs := s.oauthConfigs() - provider, ok := configs[strings.ToLower(strings.TrimSpace(providerID))] - if !ok { - if _, known := oauthProviders()[strings.ToLower(strings.TrimSpace(providerID))]; known { - return "", ErrOAuthNotConfigured - } - return "", ErrOAuthUnknownProvider - } - state, err := s.signOAuthState(provider.ID, redirect) - if err != nil { - return "", err - } - oauthConfig := s.oauthConfig(provider) - options := make([]oauth2.AuthCodeOption, 0, 2) - options = append(options, oauth2.AccessTypeOnline) - options = append(options, s.oauthAuthOptions(provider, state)...) - return oauthConfig.AuthCodeURL(state, options...), nil -} - -// oauthPKCEVerifier выводит code_verifier из подписанного state: хранить его -// отдельно не нужно, а подпись гарантирует, что верификатор не подменит -// посторонний (RFC 7636 требует 43–128 символов из a-z, A-Z, 0-9, «_», «-» — -// base64url без padding даёт ровно это). -func (s *Service) oauthPKCEVerifier(state string) string { - mac := hmac.New(sha256.New, []byte(s.stateKey)) - mac.Write([]byte("oauth-pkce:" + state)) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))[:43] -} - -// oauthAuthOptions — дополнительные параметры запроса авторизации. -func (s *Service) oauthAuthOptions(provider oauthProvider, state string) []oauth2.AuthCodeOption { - if !provider.PKCE { - return nil - } - return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(s.oauthPKCEVerifier(state))} -} - -// oauthExchangeOptions — дополнительные параметры обмена кода на токен: -// PKCE-верификатор, идентификатор устройства из callback (VK ID) и секрет под -// именем, которое ждёт провайдер (у VK ID — service_token вместо client_secret). -func (s *Service) oauthExchangeOptions(provider oauthProvider, state string, callback url.Values, credentials oauthClientCredentials) []oauth2.AuthCodeOption { - var options []oauth2.AuthCodeOption - if provider.PKCE { - options = append(options, - oauth2.VerifierOption(s.oauthPKCEVerifier(state)), - // VK ID сверяет state и при обмене кода тоже. - oauth2.SetAuthURLParam("state", state), - ) - } - if provider.DeviceIDParam != "" { - if device := strings.TrimSpace(callback.Get(provider.DeviceIDParam)); device != "" { - options = append(options, oauth2.SetAuthURLParam(provider.DeviceIDParam, device)) - } - } - if provider.TokenSecretParam != "" && credentials.clientSecret != "" { - options = append(options, oauth2.SetAuthURLParam(provider.TokenSecretParam, credentials.clientSecret)) - } - return options -} - -// OAuthCallback обменивает код на профиль, находит или создаёт аккаунт и -// выдаёт сессию. Возвращает также признак «аккаунт создан впервые». -// callback — query-параметры, которые провайдер вернул вместе с кодом: VK ID -// кладёт туда device_id, без которого обмен не пройдёт. -func (s *Service) OAuthCallback(ctx context.Context, providerID, code, state string, callback url.Values, ip, userAgent string) (*store.User, string, *store.Session, string, error) { - if strings.TrimSpace(code) == "" { - return nil, "", nil, "", fmt.Errorf("%w: empty code", ErrOAuthExchange) - } - redirect, err := s.verifyOAuthState(providerID, state) - if err != nil { - return nil, "", nil, "", err - } - configs := s.oauthConfigs() - provider, ok := configs[strings.ToLower(strings.TrimSpace(providerID))] - if !ok { - if _, known := oauthProviders()[strings.ToLower(strings.TrimSpace(providerID))]; known { - return nil, "", nil, "", ErrOAuthNotConfigured - } - return nil, "", nil, "", ErrOAuthUnknownProvider - } - oauthConfig := s.oauthConfig(provider) - client := &http.Client{Timeout: oauthHTTPTimeout} - credentials := s.oauth[provider.ID] - token, err := oauthConfig.Exchange(ctx, code, - s.oauthExchangeOptions(provider, state, callback, credentials)...) - if err != nil { - return nil, "", nil, "", fmt.Errorf("%w: exchange: %w", ErrOAuthExchange, err) - } - profile, err := provider.FetchProfile(ctx, client, token, credentials.clientID) - if err != nil { - return nil, "", nil, "", err - } - if profile.Email == "" { - return nil, "", nil, "", ErrOAuthEmailMissing - } - if !profile.EmailVerified { - return nil, "", nil, "", ErrOAuthEmailUnverified - } - email := crypto.NormalizeEmail(profile.Email) - emailIndex := s.masterKey.BlindIndex(email) - subjectIndex := s.masterKey.BlindIndex(provider.ID + ":" + profile.Subject) - - // 1. Уже привязанный внешний аккаунт — вход без создания новых записей. - if account, lookupErr := s.store.OAuthAccountBySubject(ctx, provider.ID, subjectIndex); lookupErr == nil { - user, userErr := s.store.GetUser(ctx, account.UserID) - if userErr != nil { - return nil, "", nil, "", userErr - } - if err := s.oauthBanCheck(ctx, user, ip, userAgent); err != nil { - return nil, "", nil, "", err - } - if err := s.store.TouchOAuthAccount(ctx, account.ID); err != nil { - s.logger.WarnContext(ctx, "не удалось обновить время входа OAuth-аккаунта", slog.Any("error", err)) - } - sessionToken, session, loginErr := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_login") - return user, sessionToken, session, redirect, loginErr - } else if !errors.Is(lookupErr, store.ErrNotFound) { - return nil, "", nil, "", lookupErr - } - - // 2. Аккаунт с таким email — привязываем провайдера к нему. - if user, lookupErr := s.store.GetUserByEmailIndex(ctx, emailIndex); lookupErr == nil { - if err := s.oauthBanCheck(ctx, user, ip, userAgent); err != nil { - return nil, "", nil, "", err - } - account, linkErr := s.store.LinkOAuthAccount(ctx, user.ID, provider.ID, subjectIndex, emailIndex) - if linkErr != nil { - return nil, "", nil, "", linkErr - } - if err := s.store.TouchOAuthAccount(ctx, account.ID); err != nil { - s.logger.WarnContext(ctx, "не удалось обновить время входа OAuth-аккаунта", slog.Any("error", err)) - } - _ = s.store.RecordSecurityEvent(ctx, &user.ID, "oauth_linked", ip, userAgent, - `{"provider":`+quoteJSON(provider.ID)+`}`) - if err := s.store.RecordAudit(ctx, store.AuditParams{ - ActorID: &user.ID, - Action: "user.oauth_link", - TargetType: "user", - TargetID: &user.ID, - Changes: map[string]any{"provider": provider.ID}, - }); err != nil { - s.logger.WarnContext(ctx, "не удалось записать привязку OAuth в аудит", slog.Any("error", err)) - } - sessionToken, session, loginErr := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_linked") - return user, sessionToken, session, redirect, loginErr - } else if !errors.Is(lookupErr, store.ErrNotFound) { - return nil, "", nil, "", lookupErr - } - - // 3. Новый аккаунт: регистрация должна быть разрешена (флаг инстанса). - settings, err := s.settings.InstanceSettings(ctx) - if err != nil { - return nil, "", nil, "", fmt.Errorf("load instance settings: %w", err) - } - if !settings.RegistrationEnabled { - return nil, "", nil, "", ErrRegistrationOff - } - user, err := s.createOAuthUser(ctx, email, emailIndex, profile, ip, userAgent) - if err != nil { - return nil, "", nil, "", err - } - if _, err := s.store.LinkOAuthAccount(ctx, user.ID, provider.ID, subjectIndex, emailIndex); err != nil { - return nil, "", nil, "", err - } - sessionToken, session, err := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_register") - return user, sessionToken, session, redirect, err -} - -// finishOAuthLogin создаёт сессию и пишет событие безопасности. -func (s *Service) finishOAuthLogin(ctx context.Context, user *store.User, ip, userAgent, event string) (string, *store.Session, error) { - token, session, err := s.createSession(ctx, user.ID, userAgent, ip) - if err != nil { - return "", nil, err - } - _ = s.store.RecordSecurityEvent(ctx, &user.ID, event, ip, userAgent, "") - return token, session, nil -} - -// oauthBanCheck проверяет глобальный бан инстанса до выдачи сессии (7.18). -func (s *Service) oauthBanCheck(ctx context.Context, user *store.User, ip, userAgent string) error { - banned, err := s.store.IsInstanceBanned(ctx, user.ID) - if err != nil { - return err - } - if banned { - _ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", ip, userAgent, `{"method":"oauth"}`) - return ErrUserBanned - } - return nil -} - -// createOAuthUser заводит аккаунт по данным провайдера: логин из профиля, -// пароль — случайный (вход только через провайдера), email шифруется. -func (s *Service) createOAuthUser(ctx context.Context, email, emailIndex string, profile oauthProfile, ip, userAgent string) (*store.User, error) { - username, err := s.uniqueUsername(ctx, profile.Login, email) - if err != nil { - return nil, err - } - randomPassword, _, err := crypto.NewSessionToken() - if err != nil { - return nil, err - } - passwordHash, err := s.hasher.Hash(randomPassword) - if err != nil { - return nil, fmt.Errorf("hash password: %w", err) - } - userID := s.store.NextID() - encrypted, err := s.encryptEmail(userID, email) - if err != nil { - return nil, err - } - displayName := strings.TrimSpace(profile.DisplayName) - if displayName == "" { - displayName = strings.TrimSpace(profile.Login) - } - if displayName == "" { - displayName = username - } - user, err := s.store.CreateUser(ctx, store.CreateUserParams{ - ID: userID, - Username: username, - DisplayName: defaultDisplayName(displayName, username), - EmailEnc: encrypted, - EmailIndex: emailIndex, - PasswordHash: passwordHash, - Locale: "ru", - }) - if err != nil { - return nil, err - } - _ = s.store.RecordSecurityEvent(ctx, &user.ID, "oauth_register", ip, userAgent, "") - return user, nil -} - -// uniqueUsername подбирает свободный username: из логина провайдера, иначе из -// локальной части email; суффикс добавляется только при конфликте. -func (s *Service) uniqueUsername(ctx context.Context, login, email string) (string, error) { - candidate := sanitizeUsername(login) - if candidate == "" { - local := email - if at := strings.Index(email, "@"); at > 0 { - local = email[:at] - } - candidate = sanitizeUsername(local) - } - if candidate == "" { - candidate = "user" - } - for attempt := 0; attempt < 20; attempt++ { - value := candidate - if attempt > 0 { - suffix := strconv.Itoa(attempt + 1) - limit := 32 - len(suffix) - if len(value) > limit { - value = value[:limit] - } - value += suffix - } - if _, err := s.store.GetUserByUsername(ctx, value); errors.Is(err, store.ErrNotFound) { - return value, nil - } else if err != nil { - return "", err - } - } - return "", fmt.Errorf("cannot pick a free username for oauth account") -} - -// signOAuthState подписывает state ключом сессий: хранить его в БД не нужно, -// подпись защищает от подмены (AGENT.md 9.2). -func (s *Service) signOAuthState(providerID, redirect string) (string, error) { - payload, err := json.Marshal(map[string]string{ - "provider": providerID, - "redirect": safeRedirect(redirect), - "issued": strconv.FormatInt(time.Now().UTC().Unix(), 10), - "nonce": crypto.HashToken(strconv.FormatInt(time.Now().UTC().UnixNano(), 10))[:16], - }) - if err != nil { - return "", err - } - encoded := base64.RawURLEncoding.EncodeToString(payload) - return encoded + "." + s.signStatePayload(encoded), nil -} - -// verifyOAuthState проверяет подпись, срок и провайдера, возвращая redirect. -func (s *Service) verifyOAuthState(providerID, state string) (string, error) { - parts := strings.Split(state, ".") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", ErrOAuthState - } - expected := s.signStatePayload(parts[0]) - if !hmac.Equal([]byte(expected), []byte(parts[1])) { - return "", ErrOAuthState - } - payload, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return "", ErrOAuthState - } - var values map[string]string - if err := json.Unmarshal(payload, &values); err != nil { - return "", ErrOAuthState - } - if !strings.EqualFold(values["provider"], providerID) { - return "", ErrOAuthState - } - issued, err := strconv.ParseInt(values["issued"], 10, 64) - if err != nil || time.Since(time.Unix(issued, 0).UTC()) > oauthStateTTL { - return "", ErrOAuthState - } - return safeRedirect(values["redirect"]), nil -} - -func (s *Service) signStatePayload(encoded string) string { - mac := hmac.New(sha256.New, []byte(s.stateKey)) - mac.Write([]byte("oauth-state:" + encoded)) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) -} - -// oauthConfig собирает oauth2.Config провайдера с адресом возврата. -func (s *Service) oauthConfig(provider oauthProvider) *oauth2.Config { - credentials := s.oauth[provider.ID] - config := &oauth2.Config{ - ClientID: credentials.clientID, - ClientSecret: credentials.clientSecret, - Endpoint: provider.Endpoint, - RedirectURL: strings.TrimRight(s.oauthRedirectBase, "/") + "/api/v1/auth/oauth/" + provider.ID + "/callback", - Scopes: provider.Scopes, - } - if provider.TokenSecretParam != "" { - // Секрет уходит отдельным параметром (у VK ID — service_token): - // библиотека не должна добавлять его как client_secret. - config.ClientSecret = "" - } - if provider.PKCE { - // auth-код с PKCE: без code_verifier провайдер отклонит обмен. - config.Endpoint.AuthStyle = oauth2.AuthStyleInParams - } - return config -} - -// OAuthRedirectURL возвращает адрес возврата: его оператор прописывает в -// настройках приложения у провайдера. -func (s *Service) OAuthRedirectURL() string { - return s.oauthRedirectBase + "/api/v1/auth/oauth/{provider}/callback" -} - -// safeRedirect пропускает только внутренние пути клиента: открытый редирект -// после входа недопустим (AGENT.md 9.7). -func safeRedirect(value string) string { - if value == "" || !strings.HasPrefix(value, "/") { - return "/app" - } - // `//host` и `/\host` браузеры понимают как чужой хост: оставляем только - // внутренние пути клиента (AGENT.md 9.7). - if strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\") { - return "/app" - } - return value -} - -// sanitizeUsername приводит логин провайдера к правилам glchat (7.1). -func sanitizeUsername(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - var builder strings.Builder - for _, r := range value { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '.': - builder.WriteRune(r) - case r == '-' || r == ' ': - builder.WriteByte('_') - } - if builder.Len() >= 32 { - break - } - } - result := strings.Trim(builder.String(), "._") - if len(result) < 2 { - return "" - } - return result -} - -// --- провайдеры: получение нормализованного профиля --- - -// Адреса методов «профиль пользователя» — переменные, чтобы тесты подменяли их -// на httptest-сервер (в бою значения по умолчанию). -var ( - vkUserInfoURL = "https://id.vk.ru/oauth2/user_info" - yandexInfoURL = "https://login.yandex.ru/info?format=json" -) - -// fetchVKProfile получает данные пользователя VK ID: метод API ожидает POST с -// client_id и access_token в теле (id.vk.ru/oauth2/user_info). Ответ приходит -// как {"user": {...}}, но встречается и плоский вариант — разбираем оба. -func fetchVKProfile(ctx context.Context, client *http.Client, token *oauth2.Token, clientID string) (oauthProfile, error) { - form := url.Values{ - "client_id": {clientID}, - "access_token": {token.AccessToken}, - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, - vkUserInfoURL, strings.NewReader(form.Encode())) - if err != nil { - return oauthProfile{}, fmt.Errorf("%w: build request: %w", ErrOAuthExchange, err) - } - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - request.Header.Set("Accept", "application/json") - request.Header.Set("User-Agent", "glchat") - response, err := client.Do(request) - if err != nil { - return oauthProfile{}, fmt.Errorf("%w: request: %w", ErrOAuthExchange, err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return oauthProfile{}, fmt.Errorf("%w: provider returned %d", ErrOAuthExchange, response.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - if err != nil { - return oauthProfile{}, fmt.Errorf("%w: read body: %w", ErrOAuthExchange, err) - } - var payload struct { - User *vkUser `json:"user"` - vkUser - } - if err := json.Unmarshal(body, &payload); err != nil { - return oauthProfile{}, fmt.Errorf("%w: decode profile: %w", ErrOAuthExchange, err) - } - user := payload.vkUser - if payload.User != nil { - user = *payload.User - } - if user.UserID == 0 { - return oauthProfile{}, fmt.Errorf("%w: пустой user_id в ответе VK ID", ErrOAuthExchange) - } - profile := oauthProfile{ - Subject: strconv.FormatInt(user.UserID, 10), - Login: user.Login(), - DisplayName: user.Login(), - Email: strings.TrimSpace(user.Email), - } - // VK ID отдаёт почту только по праву `email` и только если адрес - // подтверждён на стороне VK: отдельного флага в ответе нет, поэтому - // наличие адреса считаем подтверждением (см. D-083). - profile.EmailVerified = profile.Email != "" - return profile, nil -} - -// vkUser — данные пользователя VK ID. -type vkUser struct { - UserID int64 `json:"user_id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - Phone string `json:"phone"` -} - -// Login собирает логин из имени: VK ID не отдаёт публичный screen_name в -// базовых правах. -func (u vkUser) Login() string { - return strings.TrimSpace(u.FirstName + " " + u.LastName) -} - -// fetchYandexProfile получает данные пользователя Яндекс ID. Особенность: -// метод /info ждёт заголовок `Authorization: OAuth <токен>` (не Bearer), -// поэтому запрос собирается вручную, а не через oauth2-клиент. -func fetchYandexProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, yandexInfoURL, nil) - if err != nil { - return oauthProfile{}, fmt.Errorf("%w: build request: %w", ErrOAuthExchange, err) - } - request.Header.Set("Accept", "application/json") - request.Header.Set("User-Agent", "glchat") - request.Header.Set("Authorization", "OAuth "+token.AccessToken) - response, err := client.Do(request) - if err != nil { - return oauthProfile{}, fmt.Errorf("%w: request: %w", ErrOAuthExchange, err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return oauthProfile{}, fmt.Errorf("%w: provider returned %d", ErrOAuthExchange, response.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - if err != nil { - return oauthProfile{}, fmt.Errorf("%w: read body: %w", ErrOAuthExchange, err) - } - var user struct { - ID string `json:"id"` - Login string `json:"login"` - DefaultEmail string `json:"default_email"` - Emails []string `json:"emails"` - RealName string `json:"real_name"` - DisplayName string `json:"display_name"` - } - if err := json.Unmarshal(body, &user); err != nil { - return oauthProfile{}, fmt.Errorf("%w: decode profile: %w", ErrOAuthExchange, err) - } - if user.ID == "" { - return oauthProfile{}, fmt.Errorf("%w: пустой id в ответе Яндекс ID", ErrOAuthExchange) - } - email := strings.TrimSpace(user.DefaultEmail) - if email == "" && len(user.Emails) > 0 { - email = strings.TrimSpace(user.Emails[0]) - } - displayName := user.RealName - if displayName == "" { - displayName = user.DisplayName - } - return oauthProfile{ - Subject: user.ID, - Login: user.Login, - DisplayName: strings.TrimSpace(displayName), - Email: email, - // Почта приходит по праву `login:email` и принадлежит аккаунту - // Яндекса, поэтому считаем её подтверждённой (см. D-083). - EmailVerified: email != "", - }, nil -} - -func fetchGitHubProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) { - var user struct { - ID int64 `json:"id"` - Login string `json:"login"` - Email string `json:"email"` - } - if err := oauthGetJSON(ctx, client, token, "https://api.github.com/user", &user); err != nil { - return oauthProfile{}, err - } - var emails []struct { - Email string `json:"email"` - Primary bool `json:"primary"` - Verified bool `json:"verified"` - } - if err := oauthGetJSON(ctx, client, token, "https://api.github.com/user/emails", &emails); err != nil { - return oauthProfile{}, err - } - profile := oauthProfile{Subject: strconv.FormatInt(user.ID, 10), Login: user.Login} - for _, candidate := range emails { - // Берём только подтверждённый адрес: иначе чужой email в профиле GitHub - // позволил бы войти в чужой аккаунт glchat. - if candidate.Primary && candidate.Verified && candidate.Email != "" { - profile.Email = candidate.Email - profile.EmailVerified = true - break - } - } - if profile.Email == "" { - for _, candidate := range emails { - if candidate.Verified && candidate.Email != "" { - profile.Email = candidate.Email - profile.EmailVerified = true - break - } - } - } - if profile.Email == "" && user.Email != "" { - // Публичный email из профиля GitHub подтверждённым не считаем. - profile.Email = user.Email - } - return profile, nil -} - -func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) { - var user struct { - Sub string `json:"sub"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Name string `json:"name"` - } - if err := oauthGetJSON(ctx, client, token, "https://openidconnect.googleapis.com/v1/userinfo", &user); err != nil { - return oauthProfile{}, err - } - return oauthProfile{ - Subject: user.Sub, - Email: user.Email, - EmailVerified: user.EmailVerified, - Login: user.Name, - }, nil -} - -func fetchDiscordProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) { - var user struct { - ID string `json:"id"` - Username string `json:"username"` - GlobalName string `json:"global_name"` - Email string `json:"email"` - Verified bool `json:"verified"` - EmailVerified bool `json:"email_verified"` - } - if err := oauthGetJSON(ctx, client, token, "https://discord.com/api/users/@me", &user); err != nil { - return oauthProfile{}, err - } - login := user.GlobalName - if login == "" { - login = user.Username - } - return oauthProfile{ - Subject: user.ID, - Email: user.Email, - EmailVerified: user.Verified || user.EmailVerified, - Login: login, - }, nil -} - -// oauthGetJSON выполняет GET к API провайдера с токеном доступа. -func oauthGetJSON(ctx context.Context, client *http.Client, token *oauth2.Token, endpoint string, dst any) error { - if _, err := url.Parse(endpoint); err != nil { - return fmt.Errorf("%w: bad endpoint", ErrOAuthExchange) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return fmt.Errorf("%w: build request: %w", ErrOAuthExchange, err) - } - request.Header.Set("Accept", "application/json") - request.Header.Set("User-Agent", "glchat") - token.SetAuthHeader(request) - response, err := client.Do(request) - if err != nil { - return fmt.Errorf("%w: request: %w", ErrOAuthExchange, err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return fmt.Errorf("%w: provider returned %d", ErrOAuthExchange, response.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - if err != nil { - return fmt.Errorf("%w: read body: %w", ErrOAuthExchange, err) - } - if err := json.Unmarshal(body, dst); err != nil { - return fmt.Errorf("%w: decode profile: %w", ErrOAuthExchange, err) - } - return nil -} - -// oauthFromConfig собирает включённые провайдеры из конфигурации инстанса. -func oauthFromConfig(cfg config.Config, baseURL string) (map[string]oauthClientCredentials, string) { - credentials := map[string]oauthClientCredentials{} - add := func(id, clientID, clientSecret string) { - provider, known := oauthProviders()[id] - if clientID == "" { - return - } - // Публичному приложению VK ID секрет не нужен: обмен защищает PKCE. - if clientSecret == "" && (!known || !provider.SecretOptional) { - return - } - credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret} - } - add("vk", cfg.OAuthVKClientID, cfg.OAuthVKClientSecret) - add("yandex", cfg.OAuthYandexClientID, cfg.OAuthYandexClientSecret) - add("github", cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret) - add("google", cfg.OAuthGoogleClientID, cfg.OAuthGoogleClientSecret) - add("discord", cfg.OAuthDiscordClientID, cfg.OAuthDiscordClientSecret) - redirectBase := strings.TrimSpace(cfg.OAuthRedirectBase) - if redirectBase == "" { - redirectBase = baseURL - } - return credentials, redirectBase -} diff --git a/internal/auth/oauth_internal_test.go b/internal/auth/oauth_internal_test.go deleted file mode 100644 index 7620710..0000000 --- a/internal/auth/oauth_internal_test.go +++ /dev/null @@ -1,725 +0,0 @@ -package auth - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "log/slog" - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "golang.org/x/oauth2" - - "glchat/internal/config" - "glchat/internal/crypto" - "glchat/internal/database" - "glchat/internal/store" -) - -// oauthTestConfig — конфиг тестового сервиса с включённым GitHub-провайдером. -func oauthTestConfig() config.Config { - return config.Config{ - Domain: "gl.mhspx.su", - InstanceName: "glchat", - SessionPepper: "unit-test-session-pepper", - MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - TOTPEncryptionKey: "unit-test-totp-key", - SessionTTLHours: 24, - // Низкие параметры Argon2id: тесты не должны работать секундами. - Argon2MemoryKiB: 1024, - Argon2Iterations: 1, - Argon2Parallelism: 1, - OAuthGitHubClientID: "client-id", - OAuthGitHubClientSecret: "client-secret", - } -} - -// oauthTestBaseConfig — конфиг без OAuth: проверяем выключенные провайдеры. -func oauthTestBaseConfig() config.Config { - cfg := oauthTestConfig() - cfg.OAuthGitHubClientID = "" - cfg.OAuthGitHubClientSecret = "" - return cfg -} - -// oauthTestServer поднимает фальшивого провайдера: обмен кода на токен и -// профиль. Адреса подставляются в каталог провайдеров на время теста. -func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - if r.Form.Get("code") != "good-code" { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) - return - } - // Провайдер отвечает JSON: так же ведёт себя GitHub при - // `Accept: application/json`, который ставит golang.org/x/oauth2. - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": "test-access-token", - "token_type": "bearer", - "expires_in": 3600, - }) - }) - mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer test-access-token" { - w.WriteHeader(http.StatusUnauthorized) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) - }) - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - original := oauthProviderSet - oauthProviderSet = map[string]oauthProvider{ - "github": { - ID: "github", - Name: "GitHub", - Endpoint: oauth2.Endpoint{ - AuthURL: server.URL + "/authorize", - TokenURL: server.URL + "/token", - }, - Scopes: []string{"user:email"}, - FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) { - // Провайдер получает токен так же, как в бою: через oauth2-клиент. - authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) - request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil) - if err != nil { - return oauthProfile{}, err - } - response, err := authorized.Do(request) - if err != nil { - return oauthProfile{}, err - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return oauthProfile{}, errors.New("profile request failed") - } - return profile, nil - }, - }, - } - t.Cleanup(func() { oauthProviderSet = original }) - return server -} - -func oauthTestService(t *testing.T, cfg config.Config) (*Service, *store.Store) { - t.Helper() - ctx := context.Background() - db, err := database.Open(ctx, database.Options{ - Path: filepath.Join(t.TempDir(), "glchat.db"), - ReadPool: 2, - Migrate: true, - }) - if err != nil { - t.Fatalf("open database: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - st := store.New(db) - service, err := New(ctx, cfg, st, slog.New(slog.DiscardHandler)) - if err != nil { - t.Fatalf("auth.New: %v", err) - } - return service, st -} - -// TestOAuthNotConfigured — без клиентских id ручки отвечают понятной ошибкой, -// а не пытаются увести пользователя к провайдеру (AGENT.md 7.1). -func TestOAuthNotConfigured(t *testing.T) { - service, _ := oauthTestService(t, oauthTestBaseConfig()) - if service.OAuthEnabled() { - t.Fatal("oauth must be disabled without client credentials") - } - if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) { - t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err) - } - if _, err := service.OAuthAuthorizeURL("ok", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) { - t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err) - } - // VK и Яндекс в каталоге есть, но без ключей отвечают «не настроен». - for _, provider := range []string{"vk", "yandex"} { - if _, err := service.OAuthAuthorizeURL(provider, "/app"); !errors.Is(err, ErrOAuthNotConfigured) { - t.Fatalf("%s without config: err = %v, want ErrOAuthNotConfigured", provider, err) - } - } - if len(service.OAuthProviders()) != 0 { - t.Fatalf("providers = %v, want empty", service.OAuthProviders()) - } -} - -// TestOAuthAuthorizeURL — ссылка ведёт к провайдеру и несёт подписанный state. -func TestOAuthAuthorizeURL(t *testing.T) { - oauthTestServer(t, oauthProfile{Subject: "1", Email: "user@example.com", EmailVerified: true}) - service, _ := oauthTestService(t, oauthTestConfig()) - - providers := service.OAuthProviders() - if len(providers) != 1 || providers[0].ID != "github" { - t.Fatalf("providers = %v, want [github]", providers) - } - authorizeURL, err := service.OAuthAuthorizeURL("github", "/app/invite/abc") - if err != nil { - t.Fatalf("authorize url: %v", err) - } - if !strings.HasPrefix(authorizeURL, "http") || !strings.Contains(authorizeURL, "client_id=client-id") { - t.Fatalf("unexpected authorize url: %s", authorizeURL) - } - parsed, err := url.Parse(authorizeURL) - if err != nil { - t.Fatalf("parse authorize url: %v", err) - } - state := parsed.Query().Get("state") - if state == "" || !strings.Contains(state, ".") { - t.Fatalf("state is not signed: %q", state) - } - redirect, err := service.verifyOAuthState("github", state) - if err != nil { - t.Fatalf("verify state: %v", err) - } - if redirect != "/app/invite/abc" { - t.Fatalf("redirect = %q", redirect) - } - // Подмена state и чужой провайдер отклоняются. - if _, err := service.verifyOAuthState("google", state); !errors.Is(err, ErrOAuthState) { - t.Fatalf("state for another provider must fail, got %v", err) - } - if _, err := service.verifyOAuthState("github", state+"x"); !errors.Is(err, ErrOAuthState) { - t.Fatalf("tampered state must fail, got %v", err) - } -} - -// TestOAuthCallbackCreatesAndLinksAccounts — вход новым аккаунтом, привязка к -// существующему по email, повторный вход и запрет для забаненного инстанса. -func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) { - oauthTestServer(t, oauthProfile{Subject: "42", Email: "OAuth@Example.com", EmailVerified: true, Login: "Octo Cat"}) - service, st := oauthTestService(t, oauthTestConfig()) - ctx := context.Background() - - state, err := service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent") - if err != nil { - t.Fatalf("oauth callback: %v", err) - } - if redirect != "/app" || token == "" || session == nil { - t.Fatalf("unexpected callback result: redirect=%q token=%q", redirect, token) - } - if user.Username != "octo_cat" { - t.Fatalf("username = %q, want octo_cat", user.Username) - } - // Email зашифрован, наружу не отдаётся, но расшифровывается сервисом. - email, err := service.Email(ctx, user.ID) - if err != nil || email != "oauth@example.com" { - t.Fatalf("email = %q, err = %v", email, err) - } - accounts, err := st.ListOAuthAccounts(ctx, user.ID) - if err != nil || len(accounts) != 1 { - t.Fatalf("oauth accounts = %v, err = %v", accounts, err) - } - if accounts[0].SubjectIndex == "" || strings.Contains(accounts[0].SubjectIndex, "42") { - t.Fatalf("subject must be stored as blind index: %q", accounts[0].SubjectIndex) - } - - // Повторный вход тем же аккаунтом: новой записи не появляется. - state, err = service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent") - if err != nil { - t.Fatalf("second oauth callback: %v", err) - } - if again.ID != user.ID { - t.Fatalf("second login created another account: %d != %d", again.ID, user.ID) - } - - // Бан инстанса запрещает вход через провайдера. - if err := st.BanInstanceUser(ctx, user.ID, user.ID, "тест"); err != nil { - t.Fatalf("ban user: %v", err) - } - state, err = service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) { - t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err) - } -} - -// TestOAuthCallbackLinksExistingAccountByEmail — провайдер привязывается к -// уже существующему аккаунту с тем же подтверждённым email (blind index). -func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) { - oauthTestServer(t, oauthProfile{Subject: "777", Email: "existing@example.com", EmailVerified: true, Login: "existing"}) - service, st := oauthTestService(t, oauthTestConfig()) - ctx := context.Background() - - if _, _, _, err := service.Register(ctx, RegisterInput{ - Username: "existing", Email: "existing@example.com", Password: "correct-horse-battery", - }); err != nil { - t.Fatalf("register: %v", err) - } - existing, err := service.UserByEmail(ctx, "existing@example.com") - if err != nil { - t.Fatalf("find user: %v", err) - } - - state, err := service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent") - if err != nil { - t.Fatalf("oauth callback: %v", err) - } - if user.ID != existing.ID { - t.Fatalf("oauth login must reuse the account: %d != %d", user.ID, existing.ID) - } - accounts, err := st.ListOAuthAccounts(ctx, user.ID) - if err != nil || len(accounts) != 1 { - t.Fatalf("link was not created: %v, err = %v", accounts, err) - } - events, err := st.ListSecurityEvents(ctx, user.ID, 20) - if err != nil { - t.Fatalf("list security events: %v", err) - } - found := false - for _, event := range events { - if event.Type == "oauth_linked" { - found = true - } - } - if !found { - t.Fatal("security events have no oauth_linked") - } -} - -// TestOAuthCallbackRejectsUnverifiedEmail — неподтверждённый email не даёт -// войти: иначе чужой адрес в профиле провайдера уводил бы в чужой аккаунт. -func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) { - oauthTestServer(t, oauthProfile{Subject: "5", Email: "unverified@example.com", EmailVerified: false}) - service, _ := oauthTestService(t, oauthTestConfig()) - - state, err := service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) { - t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err) - } -} - -// TestOAuthCallbackStateAndCodeErrors — неверный state и код отклоняются. -func TestOAuthCallbackStateAndCodeErrors(t *testing.T) { - oauthTestServer(t, oauthProfile{Subject: "6", Email: "user@example.com", EmailVerified: true}) - service, _ := oauthTestService(t, oauthTestConfig()) - ctx := context.Background() - - if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) { - t.Fatalf("bad state: err = %v, want ErrOAuthState", err) - } - state, err := service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) { - t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err) - } - if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) { - t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err) - } -} - -// TestOAuthRegistrationDisabledBlocksNewAccounts — существующие аккаунты -// входят, новые не создаются при выключенной регистрации (7.1). -func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) { - oauthTestServer(t, oauthProfile{Subject: "8", Email: "new@example.com", EmailVerified: true, Login: "new"}) - service, st := oauthTestService(t, oauthTestConfig()) - ctx := context.Background() - if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil { - t.Fatalf("disable registration: %v", err) - } - state, err := service.signOAuthState("github", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) { - t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err) - } -} - -// TestOAuthStateExpires — state старше окна не принимается, даже если подпись -// верна: иначе ссылку можно было бы использовать неограниченно долго. -func TestOAuthStateExpires(t *testing.T) { - service, _ := oauthTestService(t, oauthTestConfig()) - payload, err := json.Marshal(map[string]string{ - "provider": "github", - "redirect": "/app", - "issued": strconv.FormatInt(time.Now().UTC().Add(-time.Hour).Unix(), 10), - "nonce": "deadbeefdeadbeef", - }) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - encoded := base64.RawURLEncoding.EncodeToString(payload) - expired := encoded + "." + service.signStatePayload(encoded) - if _, err := service.verifyOAuthState("github", expired); !errors.Is(err, ErrOAuthState) { - t.Fatalf("expired state: err = %v, want ErrOAuthState", err) - } -} - -// TestPasskeyHandleStableAndOpaque — user handle не раскрывает id аккаунта и -// стабилен между вызовами. -func TestPasskeyHandleStableAndOpaque(t *testing.T) { - service, _ := oauthTestService(t, oauthTestBaseConfig()) - first := service.passkeyHandle(12345) - second := service.passkeyHandle(12345) - if len(first) != 16 || !bytes.Equal(first, second) { - t.Fatalf("handle must be 16 stable bytes: %v / %v", first, second) - } - if strings.Contains(string(first), "12345") { - t.Fatal("handle must not contain the account id") - } - if bytes.Equal(service.passkeyHandle(12346), first) { - t.Fatal("handles of different accounts must differ") - } -} - -// TestPasskeyCeremonyStoreIsOneTime — церемония выдаётся один раз и по TTL. -func TestPasskeyCeremonyStoreIsOneTime(t *testing.T) { - store := newCeremonyStore() - now := time.Now().UTC() - store.now = func() time.Time { return now } - store.put("token", ceremony{userID: 1, expiresAt: now.Add(time.Minute)}) - if _, ok := store.take("token"); !ok { - t.Fatal("ceremony must be available once") - } - if _, ok := store.take("token"); ok { - t.Fatal("ceremony must not be reusable") - } - store.put("expired", ceremony{userID: 1, expiresAt: now.Add(-time.Second)}) - if _, ok := store.take("expired"); ok { - t.Fatal("expired ceremony must not be usable") - } -} - -// TestSanitizeUsername — логин провайдера приводится к правилам glchat. -func TestSanitizeUsername(t *testing.T) { - cases := map[string]string{ - "Octo-Cat": "octo_cat", - " John Doe ": "john_doe", - "ю": "", - "a": "", - "user.name": "user.name", - "UPPER": "upper", - } - for input, expected := range cases { - if got := sanitizeUsername(input); got != expected { - t.Errorf("sanitizeUsername(%q) = %q, want %q", input, got, expected) - } - } -} - -// TestSafeRedirect — открытый редирект невозможен. -func TestSafeRedirect(t *testing.T) { - if safeRedirect("//evil.example") != "/app" || safeRedirect("https://evil.example") != "/app" { - t.Fatal("external redirects must be rejected") - } - if safeRedirect("/app/invite/x") != "/app/invite/x" || safeRedirect("") != "/app" { - t.Fatal("internal redirects must be preserved") - } -} - -// проверяем, что ключ blind index провайдера не совпадает с email-индексом. -func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) { - key, err := crypto.ParseMasterKey(oauthTestConfig().MasterKey) - if err != nil { - t.Fatalf("parse master key: %v", err) - } - subject := key.BlindIndex("github:42") - email := key.BlindIndex("user@example.com") - if subject == email || len(subject) != len(email) { - t.Fatal("blind indexes must be independent") - } -} - -// TestOAuthVKIDFlow — вход через VK ID: PKCE (code_challenge в запросе -// авторизации и code_verifier при обмене), device_id из callback и сервисный -// ключ отдельным параметром service_token (VK ID не ждёт client_secret), -// профиль забирается методом /oauth2/user_info с client_id в теле. -func TestOAuthVKIDFlow(t *testing.T) { - mux := http.NewServeMux() - var tokenForm url.Values - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - tokenForm = r.PostForm - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": "vk-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "user_id": 42, - }) - }) - var infoForm url.Values - mux.HandleFunc("/user_info", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - infoForm = r.PostForm - _ = json.NewEncoder(w).Encode(map[string]any{ - "user": map[string]any{ - "user_id": 42, - "first_name": "Иван", - "last_name": "Петров", - "email": "ivan@example.com", - }, - }) - }) - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - originalProviders := oauthProviderSet - originalInfoURL := vkUserInfoURL - providers := defaultOAuthProviders() - vk := providers["vk"] - vk.Endpoint.AuthURL = server.URL + "/authorize" - vk.Endpoint.TokenURL = server.URL + "/token" - providers["vk"] = vk - oauthProviderSet = providers - vkUserInfoURL = server.URL + "/user_info" - t.Cleanup(func() { - oauthProviderSet = originalProviders - vkUserInfoURL = originalInfoURL - }) - - cfg := oauthTestConfig() - cfg.OAuthVKClientID = "vk-client" - cfg.OAuthVKClientSecret = "vk-service-token" - service, _ := oauthTestService(t, cfg) - if providers := service.OAuthProviders(); len(providers) != 2 || providers[0].ID != "vk" { - t.Fatalf("providers = %v, want vk первым", providers) - } - - authorizeURL, err := service.OAuthAuthorizeURL("vk", "/app/friends") - if err != nil { - t.Fatalf("authorize url: %v", err) - } - parsed, err := url.Parse(authorizeURL) - if err != nil { - t.Fatalf("parse authorize url: %v", err) - } - query := parsed.Query() - if query.Get("code_challenge") == "" || query.Get("code_challenge_method") != "S256" { - t.Fatalf("в ссылке нет PKCE: %s", authorizeURL) - } - if query.Get("client_id") != "vk-client" { - t.Fatalf("client_id = %q", query.Get("client_id")) - } - if !strings.Contains(query.Get("scope"), "email") { - t.Fatalf("scope = %q, want email", query.Get("scope")) - } - state := query.Get("state") - if state == "" { - t.Fatal("в ссылке нет state") - } - - ctx := context.Background() - user, token, session, redirect, err := service.OAuthCallback(ctx, "vk", - "vk-code", state, url.Values{"device_id": {"device-123"}}, "127.0.0.1", "agent") - if err != nil { - t.Fatalf("OAuthCallback: %v", err) - } - if token == "" || session == nil { - t.Fatalf("token=%q session=%v", token, session) - } - // Email зашифрован: наружу его отдаёт только сервис. - email, err := service.Email(ctx, user.ID) - if err != nil || email != "ivan@example.com" { - t.Fatalf("email = %q, err = %v", email, err) - } - if redirect != "/app/friends" { - t.Fatalf("redirect = %q", redirect) - } - // Обмен кода: PKCE-верификатор (43 символа), device_id и service_token. - if len(tokenForm.Get("code_verifier")) != 43 { - t.Fatalf("code_verifier = %q (len %d), want 43", tokenForm.Get("code_verifier"), len(tokenForm.Get("code_verifier"))) - } - if tokenForm.Get("device_id") != "device-123" { - t.Fatalf("device_id = %q", tokenForm.Get("device_id")) - } - if tokenForm.Get("service_token") != "vk-service-token" { - t.Fatalf("service_token = %q", tokenForm.Get("service_token")) - } - if tokenForm.Get("client_secret") != "" { - t.Fatalf("client_secret не должен уходить в VK: %q", tokenForm.Get("client_secret")) - } - if tokenForm.Get("state") != state { - t.Fatalf("state в обмене = %q, want %q", tokenForm.Get("state"), state) - } - // Профиль: POST с client_id и токеном. - if infoForm.Get("client_id") != "vk-client" || infoForm.Get("access_token") != "vk-access-token" { - t.Fatalf("user_info form = %v", infoForm) - } -} - -// TestOAuthYandexFlow — вход через Яндекс ID: обмен кода с client_id/secret в -// теле запроса и профиль по заголовку `Authorization: OAuth <токен>`. -func TestOAuthYandexFlow(t *testing.T) { - mux := http.NewServeMux() - var infoAuth string - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - if r.PostForm.Get("client_id") != "ya-client" || r.PostForm.Get("client_secret") != "ya-secret" { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": "ya-access-token", - "token_type": "bearer", - "expires_in": 3600, - }) - }) - mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { - infoAuth = r.Header.Get("Authorization") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": "1000034426", - "login": "ivan", - "default_email": "ivan@yandex.ru", - "emails": []string{"ivan@yandex.ru"}, - "real_name": "Иван Петров", - }) - }) - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - originalProviders := oauthProviderSet - originalInfoURL := yandexInfoURL - providers := defaultOAuthProviders() - yandex := providers["yandex"] - yandex.Endpoint.AuthURL = server.URL + "/authorize" - yandex.Endpoint.TokenURL = server.URL + "/token" - providers["yandex"] = yandex - oauthProviderSet = providers - yandexInfoURL = server.URL + "/info" - t.Cleanup(func() { - oauthProviderSet = originalProviders - yandexInfoURL = originalInfoURL - }) - - cfg := oauthTestConfig() - cfg.OAuthYandexClientID = "ya-client" - cfg.OAuthYandexClientSecret = "ya-secret" - service, _ := oauthTestService(t, cfg) - - authorizeURL, err := service.OAuthAuthorizeURL("yandex", "/app") - if err != nil { - t.Fatalf("authorize url: %v", err) - } - parsed, err := url.Parse(authorizeURL) - if err != nil { - t.Fatalf("parse authorize url: %v", err) - } - if parsed.Query().Get("code_challenge") != "" { - t.Fatal("Яндекс не требует PKCE: code_challenge не нужен") - } - state := parsed.Query().Get("state") - - ctx := context.Background() - user, _, _, _, err := service.OAuthCallback(ctx, "yandex", - "ya-code", state, url.Values{}, "127.0.0.1", "agent") - if err != nil { - t.Fatalf("OAuthCallback: %v", err) - } - email, err := service.Email(ctx, user.ID) - if err != nil || email != "ivan@yandex.ru" { - t.Fatalf("email = %q, err = %v", email, err) - } - if user.Username != "ivan" { - t.Fatalf("username = %q, want логин Яндекса", user.Username) - } - if infoAuth != "OAuth ya-access-token" { - t.Fatalf("Authorization = %q, want `OAuth …`", infoAuth) - } -} - -// TestOAuthVKPublicAppWithoutSecret — публичному приложению VK ID сервисный -// ключ не нужен: вход включается по одному client_id, а запрос обмена не несёт -// ни client_secret, ни service_token (защиту даёт PKCE). -func TestOAuthVKPublicAppWithoutSecret(t *testing.T) { - originalProviders := oauthProviderSet - originalInfoURL := vkUserInfoURL - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - if r.PostForm.Get("service_token") != "" || r.PostForm.Get("client_secret") != "" { - t.Errorf("публичное приложение не должно отправлять секрет: %v", r.PostForm) - } - w.Header().Set("Content-Type", "application/json") - if strings.HasSuffix(r.URL.Path, "/user_info") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "user": map[string]any{"user_id": 7, "first_name": "Пётр", "email": "petr@example.com"}, - }) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "vk-token", "token_type": "Bearer"}) - })) - t.Cleanup(server.Close) - - providers := defaultOAuthProviders() - vk := providers["vk"] - vk.Endpoint.TokenURL = server.URL + "/token" - providers["vk"] = vk - oauthProviderSet = providers - vkUserInfoURL = server.URL + "/user_info" - t.Cleanup(func() { - oauthProviderSet = originalProviders - vkUserInfoURL = originalInfoURL - }) - - cfg := oauthTestConfig() - cfg.OAuthVKClientID = "vk-public" - cfg.OAuthVKClientSecret = "" - service, _ := oauthTestService(t, cfg) - if !service.OAuthEnabled() { - t.Fatal("вход через VK должен работать без сервисного ключа") - } - state, err := service.signOAuthState("vk", "/app") - if err != nil { - t.Fatalf("sign state: %v", err) - } - ctx := context.Background() - user, _, _, _, err := service.OAuthCallback(ctx, "vk", "code", state, - url.Values{"device_id": {"device-1"}}, "127.0.0.1", "agent") - if err != nil { - t.Fatalf("OAuthCallback: %v", err) - } - email, err := service.Email(ctx, user.ID) - if err != nil || email != "petr@example.com" { - t.Fatalf("email = %q, err = %v", email, err) - } -} diff --git a/internal/auth/service.go b/internal/auth/service.go index 1f829f9..963ec41 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -60,11 +60,8 @@ type Service struct { webauthn *webauthn.WebAuthn // ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые). ceremonies *ceremonyStore - // oauth — включённые провайдеры (client id/secret из env), oauthRedirectBase — // внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state. - oauth map[string]oauthClientCredentials - oauthRedirectBase string - stateKey string + stateKey string } type instanceSettings interface { @@ -113,9 +110,8 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L sessionTTL = store.SessionTTL } - // OAuth-провайдеры и RP-конфигурация passkeys: включаются конфигом, при + // RP-конфигурация passkeys: включается конфигом, при // отсутствии настроек соответствующие ручки отвечают понятной ошибкой. - oauthClients, oauthRedirectBase := oauthFromConfig(cfg, cfg.BaseURL()) service := &Service{ store: st, @@ -126,11 +122,8 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L policy: policy, cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute}, logger: logger, - // state OAuth подписывается секретом сессий: отдельного секрета не нужно. - oauth: oauthClients, - oauthRedirectBase: oauthRedirectBase, - stateKey: cfg.SessionPepper, - ceremonies: newCeremonyStore(), + stateKey: cfg.SessionPepper, + ceremonies: newCeremonyStore(), } // Passkeys требуют корректного RP ID: для IP-адреса (стенд без домена) // браузеры WebAuthn не работают, поэтому такой инстанс просто живёт без них. diff --git a/internal/config/config.go b/internal/config/config.go index 8e353b2..8403f16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,21 +62,6 @@ type Config struct { RetainAuditDays int RetainOrphanHours int RetainInterval time.Duration - // OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами - // client id/secret, пустые значения означают «провайдер выключен». - OAuthVKClientID string - OAuthVKClientSecret string - OAuthYandexClientID string - OAuthYandexClientSecret string - OAuthGitHubClientID string - OAuthGitHubClientSecret string - OAuthGoogleClientID string - OAuthGoogleClientSecret string - OAuthDiscordClientID string - OAuthDiscordClientSecret string - // OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто — - // берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу. - OAuthRedirectBase string // Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в // base64, генерирует установщик) и контакт администратора для push-сервисов // (mailto:… или https://…). Пустой ключ — Web Push выключен. @@ -125,18 +110,6 @@ func Load() (Config, error) { RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24), RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second, - OAuthVKClientID: env("OAUTH_VK_CLIENT_ID", ""), - OAuthVKClientSecret: env("OAUTH_VK_CLIENT_SECRET", ""), - OAuthYandexClientID: env("OAUTH_YANDEX_CLIENT_ID", ""), - OAuthYandexClientSecret: env("OAUTH_YANDEX_CLIENT_SECRET", ""), - OAuthGitHubClientID: env("OAUTH_GITHUB_CLIENT_ID", ""), - OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""), - OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""), - OAuthGoogleClientSecret: env("OAUTH_GOOGLE_CLIENT_SECRET", ""), - OAuthDiscordClientID: env("OAUTH_DISCORD_CLIENT_ID", ""), - OAuthDiscordClientSecret: env("OAUTH_DISCORD_CLIENT_SECRET", ""), - OAuthRedirectBase: env("OAUTH_REDIRECT_BASE", ""), - VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""), VAPIDSubject: env("VAPID_SUBJECT", ""), @@ -188,9 +161,6 @@ func (c Config) VoiceEnabled() bool { return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != "" } -// OAuthEnabled сообщает, настроен ли хотя бы один OAuth-провайдер (AGENT.md 7.1). -func (c Config) OAuthEnabled() bool { return len(c.OAuthProviders()) > 0 } - // WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки // браузеров принимать некуда (AGENT.md 7.16). func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" } @@ -212,27 +182,6 @@ func (c Config) WebAuthnEnabled() bool { return net.ParseIP(host) == nil } -// OAuthProviders перечисляет включённые провайдеры в стабильном порядке. -func (c Config) OAuthProviders() []string { - providers := make([]string, 0, 5) - for _, candidate := range []struct { - id string - client string - secret string - }{ - {"vk", c.OAuthVKClientID, c.OAuthVKClientSecret}, - {"yandex", c.OAuthYandexClientID, c.OAuthYandexClientSecret}, - {"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret}, - {"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret}, - {"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret}, - } { - if candidate.client != "" && candidate.secret != "" { - providers = append(providers, candidate.id) - } - } - return providers -} - func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain } func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain } diff --git a/internal/database/migrations/00027_remove_oauth.sql b/internal/database/migrations/00027_remove_oauth.sql new file mode 100644 index 0000000..30ca250 --- /dev/null +++ b/internal/database/migrations/00027_remove_oauth.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Вход через внешние провайдеры (OAuth) удалён по решению владельца +-- 27.09.2026: самодостаточному инстансу «на своём сервере» он не нужен, а +-- оператору пришлось бы заводить отдельное приложение у каждого провайдера +-- (свой client id/secret, свои redirect URI, модерация) — слишком много +-- трения ради способа входа, который дублирует пароль и ключи доступа. +-- Решение и разбор — D-084. +-- +-- Таблица осталась от Фазы 7 (миграция 00019, её не переписываем: она уже +-- применена на стендах), кода, который её читает, больше нет. Данные в ней +-- могли быть только у инсталляций, которые реально включали OAuth; на наших +-- стендах провайдеры ни разу не настраивались, поэтому удаление безопасно. +DROP TABLE IF EXISTS oauth_accounts; + +-- +goose Down +-- Откат возвращает структуру таблицы (пустую): сам код входа лежит в истории +-- git, повторное включение потребует и его, и новые ключи провайдеров. +CREATE TABLE IF NOT EXISTS oauth_accounts ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider TEXT NOT NULL, + subject_index TEXT NOT NULL, + email_index TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_login_at TEXT, + UNIQUE (provider, subject_index) +); +CREATE INDEX IF NOT EXISTS oauth_accounts_user_idx ON oauth_accounts (user_id); diff --git a/internal/database/oauth_removal_migration_test.go b/internal/database/oauth_removal_migration_test.go new file mode 100644 index 0000000..85fa05b --- /dev/null +++ b/internal/database/oauth_removal_migration_test.go @@ -0,0 +1,54 @@ +package database + +import ( + "context" + "path/filepath" + "testing" + + "github.com/pressly/goose/v3" +) + +// tableExists сообщает, есть ли таблица в схеме. +func tableExists(ctx context.Context, t *testing.T, db *DB, name string) bool { + t.Helper() + var count int + if err := db.Reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&count); err != nil { + t.Fatalf("sqlite_master(%s): %v", name, err) + } + return count > 0 +} + +// TestOAuthRemovalMigration проверяет миграцию 00027 (D-084): вход через +// внешние провайдеры удалён, таблица связи внешних аккаунтов уходит из схемы. +// Откат возвращает структуру (пустую) — код входа лежит в истории git. +func TestOAuthRemovalMigration(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = db.Close() }() + + if tableExists(ctx, t, db, "oauth_accounts") { + t.Fatal("после миграций таблицы oauth_accounts быть не должно") + } + + // Откат одной миграции возвращает таблицу, повторный накат снова убирает. + if err := goose.DownToContext(ctx, db.Writer, "migrations", 26); err != nil { + t.Fatalf("down to 26: %v", err) + } + if !tableExists(ctx, t, db, "oauth_accounts") { + t.Fatal("откат 00027 должен вернуть структуру oauth_accounts") + } + if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil { + t.Fatalf("up: %v", err) + } + if tableExists(ctx, t, db, "oauth_accounts") { + t.Fatal("повторный накат 00027 должен снова удалить oauth_accounts") + } +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go index ce52034..b8580c1 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -12,11 +12,7 @@ type Features struct { VoiceURL string `json:"voice_url,omitempty"` WebPushEnabled bool `json:"web_push_enabled"` UnfurlEnabled bool `json:"unfurl_enabled"` - OAuthEnabled bool `json:"oauth_enabled"` PasskeysEnabled bool `json:"passkeys_enabled"` - // OAuthProviders — включённые провайдеры входа (id): клиент рисует по - // списку кнопки, выключенные не показывает (Фаза 7). - OAuthProviders []string `json:"oauth_providers"` } type Response struct { @@ -52,9 +48,7 @@ func New(cfg config.Config) Response { VoiceURL: cfg.LiveKitURL, WebPushEnabled: cfg.WebPushEnabled(), UnfurlEnabled: cfg.UnfurlEnabled, - OAuthEnabled: cfg.OAuthEnabled(), PasskeysEnabled: cfg.WebAuthnEnabled(), - OAuthProviders: cfg.OAuthProviders(), }, } } diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go index c480f1c..18b1531 100644 --- a/internal/meta/meta_test.go +++ b/internal/meta/meta_test.go @@ -58,7 +58,7 @@ func TestJSONUsesSnakeCaseKeys(t *testing.T) { if !ok { t.Fatalf("features is not an object: %s", raw) } - for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "oauth_enabled", "passkeys_enabled"} { + for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "passkeys_enabled"} { if _, ok := features[key]; !ok { t.Errorf("features is missing key %q", key) } diff --git a/internal/server/errors.go b/internal/server/errors.go index fb4ea43..43f73d0 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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): diff --git a/internal/server/oauth.go b/internal/server/oauth.go deleted file mode 100644 index df8a398..0000000 --- a/internal/server/oauth.go +++ /dev/null @@ -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" -} diff --git a/internal/server/oauth_test.go b/internal/server/oauth_test.go deleted file mode 100644 index 93c4e19..0000000 --- a/internal/server/oauth_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/server/server.go b/internal/server/server.go index 5cf9f1c..946d4de 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/store/oauth.go b/internal/store/oauth.go deleted file mode 100644 index f48fbe7..0000000 --- a/internal/store/oauth.go +++ /dev/null @@ -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 -} diff --git a/web/src/api/meta.ts b/web/src/api/meta.ts index e94d426..39511a4 100644 --- a/web/src/api/meta.ts +++ b/web/src/api/meta.ts @@ -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; diff --git a/web/src/api/oauth.ts b/web/src/api/oauth.ts deleted file mode 100644 index e6ace44..0000000 --- a/web/src/api/oauth.ts +++ /dev/null @@ -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 { - return request( - '/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; -} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 85d0daf..68ae0ca 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 424f42d..b8b6c04 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Внешний вид", diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 01488eb..2412777 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -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() { - {oauthReturned === 'ok' ? ( -

- {t('auth.login.oauthDone')} -

- ) : null} - {oauthErrorMessage === null ? null : ( -

- {oauthErrorMessage} -

- )} -
) : null} - {providerList.length === 0 ? null : ( -
-

{t('auth.login.oauthHint')}

- {providerList.map((provider) => ( - - ))} -
- )} -

{t('auth.login.noAccount')}{' '} {registrationEnabled ? ( diff --git a/web/src/pages/settings/LinkedAccountsSection.tsx b/web/src/pages/settings/LinkedAccountsSection.tsx deleted file mode 100644 index cf80ee0..0000000 --- a/web/src/pages/settings/LinkedAccountsSection.tsx +++ /dev/null @@ -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 ( - -

{t('settings.security.oauthTitle')}

-

{t('settings.security.oauthDescription')}

- - {enabled.length === 0 ? ( -

- {t('settings.security.oauthDisabled')} -

- ) : ( -
    - {enabled.map((provider) => ( -
  • - {provider.name} - - {t('settings.security.oauthLinkedByEmail')} - -
  • - ))} -
- )} - - ); -} diff --git a/web/src/pages/settings/SecuritySettingsPage.tsx b/web/src/pages/settings/SecuritySettingsPage.tsx index 9f6e9b0..cc2e0b4 100644 --- a/web/src/pages/settings/SecuritySettingsPage.tsx +++ b/web/src/pages/settings/SecuritySettingsPage.tsx @@ -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() { }} /> - ); } diff --git a/web/tests/app.test.tsx b/web/tests/app.test.tsx index 947828c..be0c1d8 100644 --- a/web/tests/app.test.tsx +++ b/web/tests/app.test.tsx @@ -22,7 +22,6 @@ const meta: InstanceMeta = { web_push_enabled: false, unfurl_enabled: false, passkeys_enabled: false, - oauth_enabled: false, }, }; diff --git a/web/tests/passkeys.test.tsx b/web/tests/passkeys.test.tsx index ee471b7..679b0fa 100644 --- a/web/tests/passkeys.test.tsx +++ b/web/tests/passkeys.test.tsx @@ -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'); diff --git a/web/tests/voice.test.tsx b/web/tests/voice.test.tsx index c7915a5..7f5de19 100644 --- a/web/tests/voice.test.tsx +++ b/web/tests/voice.test.tsx @@ -78,7 +78,6 @@ const enabledMeta: InstanceMeta = { web_push_enabled: false, unfurl_enabled: false, passkeys_enabled: false, - oauth_enabled: false, }, };