refactor: убрать вход через внешние провайдеры (OAuth) полностью

Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного
человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего
входа только создаёт трение (регистрация приложений, redirect URI, модерация,
чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа
(WebAuthn).

Удалено:
- сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go
  и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек;
  отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta;
- клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки
  провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры;
- установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер
  чтения существующих значений переименован в existing_value — он остался нужен
  для VAPID_SUBJECT);
- зависимость golang.org/x/oauth2 (go mod tidy).

Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем —
она применена на стендах). Откат миграции возвращает структуру; тест
TestOAuthRemovalMigration проверяет накат, откат и повторный накат.

AGENT.md (локальный) помечает пункты про OAuth как отменённые.
This commit is contained in:
2026-09-27 18:27:33 +03:00
parent c88f774749
commit a48b865bc9
27 changed files with 100 additions and 2287 deletions
-17
View File
@@ -38,23 +38,6 @@ TOTP_ENCRYPTION_KEY= # шифрование TOTP-секрет
WEBHOOK_SECRET= # подпись вебхуков WEBHOOK_SECRET= # подпись вебхуков
LIVEKIT_API_KEY= # ключ LiveKit (фаза 3) LIVEKIT_API_KEY= # ключ LiveKit (фаза 3)
LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3) LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3)
# --- OAuth-провайдеры (Фаза 7): вход через внешние сервисы ---
# Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured.
# Адрес возврата для настроек приложения у провайдера:
# <домен>/api/v1/auth/oauth/<github|google|discord>/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): уведомления в браузере и на телефоне --- # --- Web Push (Фаза 7): уведомления в браузере и на телефоне ---
# Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто — # Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто —
# Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`. # Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`.
+5 -52
View File
@@ -718,9 +718,10 @@ generate_secrets() {
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index, # расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие # LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets. # данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
# oauth_value отдаёт значение OAuth-настройки: сначала из окружения # existing_value отдаёт значение необязательной настройки: сначала из
# (оператор задал при запуске), затем из существующего .env. # окружения (оператор задал при запуске), затем из существующего .env —
oauth_value() { # чтобы `--reconfigure` её не потерял.
existing_value() {
local key="$1" local key="$1"
local value="${!key:-}" local value="${!key:-}"
if [ -z "$value" ] && [ "$env_existing" = "1" ]; then if [ -z "$value" ] && [ "$env_existing" = "1" ]; then
@@ -774,7 +775,7 @@ generate_secrets() {
fi fi
# Контакт администратора для push-сервисов: значение можно задать заранее, # Контакт администратора для push-сервисов: значение можно задать заранее,
# иначе берём email администратора или адрес домена. # иначе берём email администратора или адрес домена.
VAPID_SUBJECT="$(oauth_value VAPID_SUBJECT)" VAPID_SUBJECT="$(existing_value VAPID_SUBJECT)"
if [ -z "$VAPID_SUBJECT" ]; then if [ -z "$VAPID_SUBJECT" ]; then
if [ -n "${ADMIN_EMAIL:-}" ]; then if [ -n "${ADMIN_EMAIL:-}" ]; then
VAPID_SUBJECT="mailto:${ADMIN_EMAIL}" VAPID_SUBJECT="mailto:${ADMIN_EMAIL}"
@@ -783,21 +784,6 @@ generate_secrets() {
fi fi
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 if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)" log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)"
ok "секреты подготовлены (dry-run)" ok "секреты подготовлены (dry-run)"
@@ -972,24 +958,6 @@ BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@
BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@ BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@
AGE_RECIPIENT=@AGE_RECIPIENT@ AGE_RECIPIENT=@AGE_RECIPIENT@
# --- OAuth-провайдеры (Фаза 7) ---
# Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth/<id>/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) --- # --- Web Push (Фаза 7) ---
# Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен. # Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен.
VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@ VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@
@@ -1066,21 +1034,6 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL} 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-ключ переиспользуется при переустановке. # Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке.
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT") VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT")
+2 -3
View File
@@ -3,6 +3,7 @@ module glchat
go 1.26.0 go 1.26.0
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
github.com/danielgtaylor/huma/v2 v2.39.1 github.com/danielgtaylor/huma/v2 v2.39.1
github.com/fxamacker/cbor/v2 v2.9.4 github.com/fxamacker/cbor/v2 v2.9.4
@@ -12,11 +13,10 @@ require (
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.28.0 github.com/pressly/goose/v3 v3.28.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.37.0 golang.org/x/net v0.59.0
) )
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // 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/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // 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/sync v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
) )
-2
View File
@@ -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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= 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/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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-877
View File
@@ -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
}
-725
View File
@@ -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)
}
}
+1 -8
View File
@@ -60,10 +60,7 @@ type Service struct {
webauthn *webauthn.WebAuthn webauthn *webauthn.WebAuthn
// ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые). // ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые).
ceremonies *ceremonyStore ceremonies *ceremonyStore
// oauth — включённые провайдеры (client id/secret из env), oauthRedirectBase —
// внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state. // внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state.
oauth map[string]oauthClientCredentials
oauthRedirectBase string
stateKey string stateKey string
} }
@@ -113,9 +110,8 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
sessionTTL = store.SessionTTL sessionTTL = store.SessionTTL
} }
// OAuth-провайдеры и RP-конфигурация passkeys: включаются конфигом, при // RP-конфигурация passkeys: включается конфигом, при
// отсутствии настроек соответствующие ручки отвечают понятной ошибкой. // отсутствии настроек соответствующие ручки отвечают понятной ошибкой.
oauthClients, oauthRedirectBase := oauthFromConfig(cfg, cfg.BaseURL())
service := &Service{ service := &Service{
store: st, store: st,
@@ -126,9 +122,6 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
policy: policy, policy: policy,
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute}, cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
logger: logger, logger: logger,
// state OAuth подписывается секретом сессий: отдельного секрета не нужно.
oauth: oauthClients,
oauthRedirectBase: oauthRedirectBase,
stateKey: cfg.SessionPepper, stateKey: cfg.SessionPepper,
ceremonies: newCeremonyStore(), ceremonies: newCeremonyStore(),
} }
-51
View File
@@ -62,21 +62,6 @@ type Config struct {
RetainAuditDays int RetainAuditDays int
RetainOrphanHours int RetainOrphanHours int
RetainInterval time.Duration 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 в // Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
// base64, генерирует установщик) и контакт администратора для push-сервисов // base64, генерирует установщик) и контакт администратора для push-сервисов
// (mailto:… или https://…). Пустой ключ — Web Push выключен. // (mailto:… или https://…). Пустой ключ — Web Push выключен.
@@ -125,18 +110,6 @@ func Load() (Config, error) {
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24), RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second, 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", ""), VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: env("VAPID_SUBJECT", ""), VAPIDSubject: env("VAPID_SUBJECT", ""),
@@ -188,9 +161,6 @@ func (c Config) VoiceEnabled() bool {
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != "" 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-ключа подписки // WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки
// браузеров принимать некуда (AGENT.md 7.16). // браузеров принимать некуда (AGENT.md 7.16).
func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" } func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" }
@@ -212,27 +182,6 @@ func (c Config) WebAuthnEnabled() bool {
return net.ParseIP(host) == nil 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) BaseURL() string { return c.Scheme() + "://" + c.Domain }
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain } func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
@@ -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);
@@ -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")
}
}
-6
View File
@@ -12,11 +12,7 @@ type Features struct {
VoiceURL string `json:"voice_url,omitempty"` VoiceURL string `json:"voice_url,omitempty"`
WebPushEnabled bool `json:"web_push_enabled"` WebPushEnabled bool `json:"web_push_enabled"`
UnfurlEnabled bool `json:"unfurl_enabled"` UnfurlEnabled bool `json:"unfurl_enabled"`
OAuthEnabled bool `json:"oauth_enabled"`
PasskeysEnabled bool `json:"passkeys_enabled"` PasskeysEnabled bool `json:"passkeys_enabled"`
// OAuthProviders — включённые провайдеры входа (id): клиент рисует по
// списку кнопки, выключенные не показывает (Фаза 7).
OAuthProviders []string `json:"oauth_providers"`
} }
type Response struct { type Response struct {
@@ -52,9 +48,7 @@ func New(cfg config.Config) Response {
VoiceURL: cfg.LiveKitURL, VoiceURL: cfg.LiveKitURL,
WebPushEnabled: cfg.WebPushEnabled(), WebPushEnabled: cfg.WebPushEnabled(),
UnfurlEnabled: cfg.UnfurlEnabled, UnfurlEnabled: cfg.UnfurlEnabled,
OAuthEnabled: cfg.OAuthEnabled(),
PasskeysEnabled: cfg.WebAuthnEnabled(), PasskeysEnabled: cfg.WebAuthnEnabled(),
OAuthProviders: cfg.OAuthProviders(),
}, },
} }
} }
+1 -1
View File
@@ -58,7 +58,7 @@ func TestJSONUsesSnakeCaseKeys(t *testing.T) {
if !ok { if !ok {
t.Fatalf("features is not an object: %s", raw) 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 { if _, ok := features[key]; !ok {
t.Errorf("features is missing key %q", key) t.Errorf("features is missing key %q", key)
} }
-19
View File
@@ -81,25 +81,6 @@ func newAPIError(err error) apiError {
case errors.Is(err, auth.ErrPasskeyLimit): case errors.Is(err, auth.ErrPasskeyLimit):
candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit" candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit"
candidate.Message = "passkey limit reached for this account" 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): case errors.Is(err, store.ErrNotFound):
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found" candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
case errors.Is(err, store.ErrConflict): case errors.Is(err, store.ErrConflict):
-110
View File
@@ -1,110 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/httpx"
)
// registerOAuthRoutes вешает ручки входа через внешние провайдеры (AGENT.md 7.1).
// `start` и `callback` — публичные (это вход), лимит по IP общий с другими
// ручками аутентификации.
func (s *Server) registerOAuthRoutes(router chi.Router) {
oauthLimit := s.oauthLimiter.Middleware(func(r *http.Request) string {
return httpx.ClientIP(r, nil)
})
router.Get("/auth/oauth/providers", s.handleOAuthProviders)
router.With(oauthLimit).Get("/auth/oauth/{provider}/start", s.handleOAuthStart)
router.With(oauthLimit).Get("/auth/oauth/{provider}/callback", s.handleOAuthCallback)
}
// handleOAuthProviders отдаёт список включённых провайдеров: клиент рисует по
// нему кнопки входа, а закрытые провайдеры не показывает вовсе.
func (s *Server) handleOAuthProviders(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeJSON(w, map[string]any{"providers": []any{}, "redirect_url": ""})
return
}
writeJSON(w, map[string]any{
"providers": s.auth.OAuthProviders(),
"redirect_url": s.auth.OAuthRedirectURL(),
})
}
// handleOAuthStart уводит браузер на страницу согласия провайдера.
// Если провайдер не настроен — отвечаем понятной ошибкой, а не редиректом.
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
provider := chi.URLParam(r, "provider")
redirect := r.URL.Query().Get("redirect")
authorizeURL, err := s.auth.OAuthAuthorizeURL(provider, redirect)
if err != nil {
writeAPIError(w, err)
return
}
//nolint:gosec // адрес берётся из каталога провайдеров, пользователь его не задаёт
http.Redirect(w, r, authorizeURL, http.StatusFound)
}
// handleOAuthCallback принимает код, находит или создаёт аккаунт и возвращает
// пользователя в клиент. Ошибки уходят в `?oauth_error=<код>`: браузер видит
// понятное сообщение на странице входа, а не JSON.
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
provider := chi.URLParam(r, "provider")
query := r.URL.Query()
_, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider,
query.Get("code"), query.Get("state"), query,
httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context()))
if err != nil {
code := oauthErrorCode(err)
s.logger.InfoContext(r.Context(), "вход через OAuth не удался",
slog.String("provider", provider), slog.String("code", code))
http.Redirect(w, r, "/login?oauth_error="+url.QueryEscape(code), http.StatusFound)
return
}
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
// Признак в адресе нужен клиенту, чтобы перечитать профиль после возврата.
target := redirect
if target == "" || target == "/" {
target = "/app"
}
separator := "?"
if strings.Contains(target, "?") {
separator = "&"
}
//nolint:gosec // target — только внутренний путь: его проверил safeRedirect
http.Redirect(w, r, target+separator+"oauth=ok", http.StatusFound)
}
// oauthErrorCode переводит доменную ошибку в код для `?oauth_error=`.
func oauthErrorCode(err error) string {
for _, candidate := range []error{
auth.ErrOAuthNotConfigured,
auth.ErrOAuthUnknownProvider,
auth.ErrOAuthState,
auth.ErrOAuthEmailUnverified,
auth.ErrOAuthEmailMissing,
auth.ErrRegistrationOff,
auth.ErrUserBanned,
auth.ErrOAuthExchange,
} {
if errors.Is(err, candidate) {
return strings.TrimPrefix(candidate.Error(), "oauth.")
}
}
return "failed"
}
-92
View File
@@ -1,92 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает
// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7).
func TestOAuthProviderNotConfigured(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
}
// VK и Яндекс есть в каталоге, но без ключей тоже отвечают «не настроен».
for _, provider := range []string{"vk", "yandex"} {
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("%s start without config = %d, want 404", provider, rec.Code)
}
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
t.Fatalf("%s error code = %q, want oauth.provider_not_configured", provider, code)
}
}
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/ok/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown provider = %d, want 404", rec.Code)
}
if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" {
t.Fatalf("error code = %q, want oauth.provider_unknown", code)
}
// Список провайдеров пуст, клиент не рисует кнопки.
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "")
if rec.Code != http.StatusOK {
t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String())
}
var payload struct {
Providers []struct {
ID string `json:"id"`
} `json:"providers"`
RedirectURL string `json:"redirect_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode providers: %v", err)
}
if len(payload.Providers) != 0 {
t.Fatalf("providers = %s, want empty", rec.Body.String())
}
// Мета инстанса честно сообщает, что провайдеры выключены.
meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
if meta.Code != http.StatusOK {
t.Fatalf("meta = %d", meta.Code)
}
if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) {
t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String())
}
if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) {
t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String())
}
}
// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state
// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию.
func TestOAuthCallbackRejectsUnsignedState(t *testing.T) {
srv, _ := newTestServer(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusFound {
t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "/login?oauth_error=") {
t.Fatalf("callback must redirect to the login page with a code, got %q", location)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("failed oauth callback must not set a session cookie")
}
}
+1 -4
View File
@@ -71,10 +71,9 @@ type Server struct {
uploadDailyLimiter *httpx.RateLimiter uploadDailyLimiter *httpx.RateLimiter
// reactionLimiter — 20 реакций в минуту на пользователя. // reactionLimiter — 20 реакций в минуту на пользователя.
reactionLimiter *httpx.RateLimiter reactionLimiter *httpx.RateLimiter
// passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7): // passkeyLimiter ограничивает ручки входа по ключу доступа (Фаза 7):
// 10 запросов в минуту на IP — с запасом на пару begin/finish. // 10 запросов в минуту на IP — с запасом на пару begin/finish.
passkeyLimiter *httpx.RateLimiter passkeyLimiter *httpx.RateLimiter
oauthLimiter *httpx.RateLimiter
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами // pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6). // уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
pushLimiter *httpx.RateLimiter 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), uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
reactionLimiter: httpx.NewRateLimiter(20, 20), reactionLimiter: httpx.NewRateLimiter(20, 20),
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10), passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10), pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные // Новые превью ссылок: 20 в минуту на пользователя (кэшированные
// ответы лимит не тратят). // ответы лимит не тратят).
@@ -203,7 +201,6 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerAuthRoutes(apiRouter) s.registerAuthRoutes(apiRouter)
if deps.Auth != nil { if deps.Auth != nil {
s.registerPasskeyRoutes(apiRouter) s.registerPasskeyRoutes(apiRouter)
s.registerOAuthRoutes(apiRouter)
} }
if deps.Store != nil { if deps.Store != nil {
s.registerUserRoutes(s.api) s.registerUserRoutes(s.api)
-95
View File
@@ -1,95 +0,0 @@
package store
import (
"context"
"database/sql"
"time"
)
// OAuthAccount — связь внешнего провайдера с пользователем инстанса.
// Сырого идентификатора и email здесь нет: только blind index (AGENT.md 9.2).
type OAuthAccount struct {
ID uint64
UserID uint64
Provider string
SubjectIndex string
EmailIndex string
CreatedAt time.Time
LastLoginAt *time.Time
}
// LinkOAuthAccount привязывает внешний аккаунт к пользователю. Повторная
// привязка того же провайдера к другому пользователю отклоняется уникальным
// индексом (provider, subject_index) — это защита от подмены владельца.
func (s *Store) LinkOAuthAccount(ctx context.Context, userID uint64, provider, subjectIndex, emailIndex string) (*OAuthAccount, error) {
id := s.NextID()
_, err := s.writer.ExecContext(ctx, `
INSERT INTO oauth_accounts (id, user_id, provider, subject_index, email_index, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
int64(id), int64(userID), provider, subjectIndex, emailIndex, s.Now())
if err != nil {
return nil, mapError(err)
}
return s.GetOAuthAccount(ctx, id)
}
func (s *Store) GetOAuthAccount(ctx context.Context, id uint64) (*OAuthAccount, error) {
row := s.reader.QueryRowContext(ctx, `
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
FROM oauth_accounts WHERE id = ?`, int64(id))
return scanOAuthAccount(row)
}
// OAuthAccountBySubject ищет привязку по blind index внешнего идентификатора.
func (s *Store) OAuthAccountBySubject(ctx context.Context, provider, subjectIndex string) (*OAuthAccount, error) {
row := s.reader.QueryRowContext(ctx, `
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
FROM oauth_accounts WHERE provider = ? AND subject_index = ?`, provider, subjectIndex)
return scanOAuthAccount(row)
}
// ListOAuthAccounts возвращает привязки пользователя: показываются в настройках
// безопасности, чтобы входы через провайдеров не были невидимыми.
func (s *Store) ListOAuthAccounts(ctx context.Context, userID uint64) ([]OAuthAccount, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
FROM oauth_accounts WHERE user_id = ? ORDER BY created_at`, int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
accounts := make([]OAuthAccount, 0, 2)
for rows.Next() {
account, err := scanOAuthAccount(rows)
if err != nil {
return nil, err
}
accounts = append(accounts, *account)
}
return accounts, rows.Err()
}
// TouchOAuthAccount отмечает вход через провайдера.
func (s *Store) TouchOAuthAccount(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx, `UPDATE oauth_accounts SET last_login_at = ? WHERE id = ?`, s.Now(), int64(id))
return err
}
func scanOAuthAccount(scanner interface{ Scan(...any) error }) (*OAuthAccount, error) {
var (
account OAuthAccount
createdAt string
lastLoginAt sql.NullString
)
if err := scanner.Scan(&account.ID, &account.UserID, &account.Provider, &account.SubjectIndex,
&account.EmailIndex, &createdAt, &lastLoginAt); err != nil {
return nil, mapError(err)
}
account.CreatedAt = parseTimestamp(createdAt)
if lastLoginAt.Valid {
value := parseTimestamp(lastLoginAt.String)
account.LastLoginAt = &value
}
return &account, nil
}
-1
View File
@@ -5,7 +5,6 @@ export interface InstanceFeatures {
anti_bot_enabled: boolean; anti_bot_enabled: boolean;
voice_enabled: boolean; voice_enabled: boolean;
web_push_enabled: boolean; web_push_enabled: boolean;
oauth_enabled: boolean;
passkeys_enabled: boolean; passkeys_enabled: boolean;
/** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */ /** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */
unfurl_enabled: boolean; unfurl_enabled: boolean;
-41
View File
@@ -1,41 +0,0 @@
import { request } from './client';
/** Провайдер входа, включённый на инстансе (AGENT.md 7.1, Фаза 7). */
export interface OAuthProvider {
id: string;
name: string;
}
export interface OAuthProvidersResponse {
providers: OAuthProvider[];
/** Адрес возврата: его оператор прописывает в настройках приложения. */
redirect_url: string;
}
export function fetchOAuthProviders(signal?: AbortSignal): Promise<OAuthProvidersResponse> {
return request<OAuthProvidersResponse>(
'/auth/oauth/providers',
signal === undefined ? {} : { signal },
);
}
/**
* Ссылка входа через провайдера. Браузер уходит по ней целиком (обычная
* навигация): cookie сессии выставляет сервер после возврата.
*/
export function oauthStartURL(provider: string, redirect?: string): string {
const query = new URLSearchParams();
if (redirect !== undefined && redirect !== '') {
query.set('redirect', redirect);
}
const suffix = query.toString();
return `/api/v1/auth/oauth/${encodeURIComponent(provider)}/start${suffix === '' ? '' : `?${suffix}`}`;
}
/** Код ошибки возврата (`?oauth_error=`), если вход через провайдера не удался. */
export function oauthErrorCode(value: string | null): string | null {
if (value === null || value === '') {
return null;
}
return value;
}
+2 -19
View File
@@ -81,12 +81,6 @@
"auth.passkey_unknown": "This key is not registered on the account.", "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_name_invalid": "Enter a key name (up to 64 characters).",
"auth.passkey_limit": "The account reached its passkey limit.", "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.", "permissions.denied": "You do not have permission for this action.",
"instance.admin_required": "This action is available to the instance admin only.", "instance.admin_required": "This action is available to the instance admin only.",
"guild.not_found": "Server not found.", "guild.not_found": "Server not found.",
@@ -194,14 +188,7 @@
"passkeySubmit": "Sign in with a passkey", "passkeySubmit": "Sign in with a passkey",
"passkeyPending": "Waiting for the key…", "passkeyPending": "Waiting for the key…",
"passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.", "passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.",
"passkeyUnsupported": "This browser does not support passkeys. Sign in with your password.", "passkeyUnsupported": "This browser does not support passkeys. Sign in with your password."
"oauthHint": "Or sign in with an external service:",
"oauthSubmit": "Sign in with {{provider}}",
"oauthDone": "Signed in. Finishing…",
"oauthFailed": "External sign-in failed. Try again or use your password.",
"oauthNotConfigured": "This sign-in method is disabled on the instance. Use your password.",
"oauthEmailUnverified": "The provider did not confirm your email — sign-in is impossible.",
"oauthEmailMissing": "The provider returned no email — use your password or contact the administrator."
}, },
"register": { "register": {
"title": "Create account", "title": "Create account",
@@ -900,11 +887,7 @@
"passkeysTimeout": "The authenticator did not respond in time. Try again.", "passkeysTimeout": "The authenticator did not respond in time. Try again.",
"passkeysAlreadyRegistered": "This key is already registered on the account.", "passkeysAlreadyRegistered": "This key is already registered on the account.",
"passkeysFailed": "Could not create the key. Try another authenticator.", "passkeysFailed": "Could not create the key. Try another authenticator.",
"passkeysNameRequired": "Enter a key name (up to 64 characters).", "passkeysNameRequired": "Enter a key name (up to 64 characters)."
"oauthTitle": "External sign-in providers",
"oauthDescription": "Providers are enabled by the instance administrator. An account is linked automatically on sign-in when the provider confirms the email.",
"oauthDisabled": "No external providers are configured on this instance.",
"oauthLinkedByEmail": "sign-in via confirmed email"
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
+2 -19
View File
@@ -81,12 +81,6 @@
"auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.", "auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.",
"auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).", "auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).",
"auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.", "auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.",
"oauth.provider_not_configured": "Провайдер не настроен на инстансе.",
"oauth.provider_unknown": "Неизвестный провайдер входа.",
"oauth.state_invalid": "Ссылка входа устарела. Начните вход заново.",
"oauth.email_unverified": "Провайдер не подтвердил почту — вход невозможен.",
"oauth.email_missing": "Провайдер не отдал почту — вход невозможен.",
"oauth.exchange_failed": "Провайдер отклонил запрос. Попробуйте позже.",
"permissions.denied": "Недостаточно прав для этого действия.", "permissions.denied": "Недостаточно прав для этого действия.",
"instance.admin_required": "Действие доступно только администратору инстанса.", "instance.admin_required": "Действие доступно только администратору инстанса.",
"guild.not_found": "Сервер не найден.", "guild.not_found": "Сервер не найден.",
@@ -194,14 +188,7 @@
"passkeySubmit": "Войти по ключу доступа", "passkeySubmit": "Войти по ключу доступа",
"passkeyPending": "Ждём ключ…", "passkeyPending": "Ждём ключ…",
"passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.", "passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.",
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем.", "passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем."
"oauthHint": "Или войдите через внешний сервис:",
"oauthSubmit": "Войти через {{provider}}",
"oauthDone": "Вход выполнен. Завершаем…",
"oauthFailed": "Вход через внешний сервис не удался. Попробуйте ещё раз или войдите с паролем.",
"oauthNotConfigured": "Этот способ входа выключен на инстансе. Войдите с паролем.",
"oauthEmailUnverified": "Внешний сервис не подтвердил вашу почту — вход невозможен.",
"oauthEmailMissing": "Внешний сервис не отдал почту — войдите с паролем или обратитесь к администратору."
}, },
"register": { "register": {
"title": "Регистрация", "title": "Регистрация",
@@ -900,11 +887,7 @@
"passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.", "passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.",
"passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.", "passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.",
"passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.", "passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.",
"passkeysNameRequired": "Укажите название ключа (до 64 символов).", "passkeysNameRequired": "Укажите название ключа (до 64 символов)."
"oauthTitle": "Вход через внешние сервисы",
"oauthDescription": "Провайдеры включаются администратором инстанса. Аккаунт привязывается автоматически при входе, если почта подтверждена провайдером.",
"oauthDisabled": "На этом инстансе внешние провайдеры не настроены.",
"oauthLinkedByEmail": "вход по подтверждённой почте"
}, },
"appearance": { "appearance": {
"title": "Внешний вид", "title": "Внешний вид",
-53
View File
@@ -5,7 +5,6 @@ import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { login } from '@/api/auth'; import { login } from '@/api/auth';
import { fetchMeta, metaQueryKey } from '@/api/meta'; import { fetchMeta, metaQueryKey } from '@/api/meta';
import { fetchOAuthProviders, oauthStartURL } from '@/api/oauth';
import { loginWithPasskey } from '@/api/passkeys'; import { loginWithPasskey } from '@/api/passkeys';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field'; import { Field } from '@/components/ui/Field';
@@ -46,13 +45,6 @@ export default function LoginPage() {
const stateFrom = (location.state as LocationState | null)?.from ?? null; const stateFrom = (location.state as LocationState | null)?.from ?? null;
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app'); 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-адресе (стенд // Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд
// без домена) WebAuthn невозможен, и кнопку показывать нельзя. // без домена) WebAuthn невозможен, и кнопку показывать нельзя.
const meta = useQuery({ const meta = useQuery({
@@ -97,24 +89,8 @@ export default function LoginPage() {
}; };
const registrationEnabled = instance.data?.registration_enabled === true; 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 важнее серверной: она объясняет, что делать. // Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать.
const passkeyErrorKey = const passkeyErrorKey =
passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled' passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled'
@@ -131,17 +107,6 @@ export default function LoginPage() {
</header> </header>
<Card> <Card>
{oauthReturned === 'ok' ? (
<p className="mb-4 text-sm text-success" role="status">
{t('auth.login.oauthDone')}
</p>
) : null}
{oauthErrorMessage === null ? null : (
<p className="mb-4 text-sm text-danger" role="alert" data-testid="oauth-error">
{oauthErrorMessage}
</p>
)}
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate> <form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field <Field
label={t('auth.login.email')} label={t('auth.login.email')}
@@ -209,24 +174,6 @@ export default function LoginPage() {
</div> </div>
) : null} ) : null}
{providerList.length === 0 ? null : (
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-4">
<p className="text-xs text-fg-muted">{t('auth.login.oauthHint')}</p>
{providerList.map((provider) => (
<Button
key={provider.id}
variant="ghost"
data-testid={`oauth-${provider.id}`}
onClick={() => {
window.location.assign(oauthStartURL(provider.id, from));
}}
>
{t('auth.login.oauthSubmit', { provider: provider.name })}
</Button>
))}
</div>
)}
<p className="mt-4 text-sm text-fg-muted"> <p className="mt-4 text-sm text-fg-muted">
{t('auth.login.noAccount')}{' '} {t('auth.login.noAccount')}{' '}
{registrationEnabled ? ( {registrationEnabled ? (
@@ -1,47 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchOAuthProviders } from '@/api/oauth';
import { Card } from '@/components/ui/primitives';
/**
* Вход через внешние провайдеры (Фаза 7). Раздел только показывает, какие
* провайдеры включены на инстансе: привязка происходит при входе по
* подтверждённому email, а отвязка — действие оператора/администратора.
*/
export function LinkedAccountsSection() {
const { t } = useTranslation();
const providers = useQuery({
queryKey: ['auth', 'oauth', 'providers'],
queryFn: ({ signal }) => fetchOAuthProviders(signal),
staleTime: 5 * 60 * 1000,
});
const enabled = providers.data?.providers ?? [];
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.security.oauthTitle')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.security.oauthDescription')}</p>
{enabled.length === 0 ? (
<p className="mt-3 text-sm text-fg-muted" data-testid="oauth-none">
{t('settings.security.oauthDisabled')}
</p>
) : (
<ul className="mt-3 flex flex-col gap-2" data-testid="oauth-providers">
{enabled.map((provider) => (
<li
key={provider.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3 text-sm"
>
<span>{provider.name}</span>
<span className="text-xs text-fg-muted">
{t('settings.security.oauthLinkedByEmail')}
</span>
</li>
))}
</ul>
)}
</Card>
);
}
@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users'; import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
import { LinkedAccountsSection } from '@/pages/settings/LinkedAccountsSection';
import { PasskeysSection } from '@/pages/settings/PasskeysSection'; import { PasskeysSection } from '@/pages/settings/PasskeysSection';
import { PasswordSection } from '@/pages/settings/PasswordSection'; import { PasswordSection } from '@/pages/settings/PasswordSection';
import { SessionsSection } from '@/pages/settings/SessionsSection'; import { SessionsSection } from '@/pages/settings/SessionsSection';
@@ -39,7 +38,6 @@ export default function SecuritySettingsPage() {
}} }}
/> />
<PasskeysSection twoFactorEnabled={twoFactorEnabled} /> <PasskeysSection twoFactorEnabled={twoFactorEnabled} />
<LinkedAccountsSection />
</div> </div>
); );
} }
-1
View File
@@ -22,7 +22,6 @@ const meta: InstanceMeta = {
web_push_enabled: false, web_push_enabled: false,
unfurl_enabled: false, unfurl_enabled: false,
passkeys_enabled: false, passkeys_enabled: false,
oauth_enabled: false,
}, },
}; };
+1 -38
View File
@@ -310,13 +310,11 @@ const metaWithPasskeys = {
voice_enabled: false, voice_enabled: false,
web_push_enabled: false, web_push_enabled: false,
unfurl_enabled: false, unfurl_enabled: false,
oauth_enabled: true,
passkeys_enabled: true, passkeys_enabled: true,
oauth_providers: ['github'],
}, },
}; };
describe('страница входа: ключи и внешние провайдеры', () => { describe('страница входа: ключи доступа', () => {
it('входит по ключу и переводит в приложение', async () => { it('входит по ключу и переводит в приложение', async () => {
const assertion = { const assertion = {
id: 'credential-id', id: 'credential-id',
@@ -359,11 +357,6 @@ describe('страница входа: ключи и внешние провай
method: 'POST', method: 'POST',
response: () => json({ user }), 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', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]); ]);
@@ -371,8 +364,6 @@ describe('страница входа: ключи и внешние провай
const { router } = renderApp('/login'); const { router } = renderApp('/login');
const visitor = userEvent.setup(); const visitor = userEvent.setup();
// Кнопка внешнего провайдера нарисована по списку с сервера.
expect(await screen.findByTestId('oauth-github')).toBeVisible();
await visitor.click(await screen.findByTestId('passkey-login')); await visitor.click(await screen.findByTestId('passkey-login'));
await waitFor(() => { await waitFor(() => {
@@ -382,39 +373,11 @@ describe('страница входа: ключи и внешние провай
expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login'); 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 () => { it('без поддержки браузера кнопка входа по ключу не показывается', async () => {
vi.stubGlobal('PublicKeyCredential', undefined); vi.stubGlobal('PublicKeyCredential', undefined);
installFetch([ installFetch([
{ match: '/api/v1/meta', response: () => json(metaWithPasskeys) }, { match: '/api/v1/meta', response: () => json(metaWithPasskeys) },
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{
match: '/api/v1/auth/oauth/providers',
response: () => json({ providers: [], redirect_url: 'https://x' }),
},
]); ]);
renderApp('/login'); renderApp('/login');
-1
View File
@@ -78,7 +78,6 @@ const enabledMeta: InstanceMeta = {
web_push_enabled: false, web_push_enabled: false,
unfurl_enabled: false, unfurl_enabled: false,
passkeys_enabled: false, passkeys_enabled: false,
oauth_enabled: false,
}, },
}; };