feat(auth): вход через VK ID и Яндекс ID
Владельцу нужны VK и Яндекс (Google/Discord/GitHub остаются в каталоге и включаются своими ключами). VK ID (id.vk.ru, OAuth 2.1): - обязательный PKCE: code_challenge в запросе авторизации, code_verifier при обмене; верификатор выводится из подписанного state (HMAC), хранить нечего; - device_id из callback уходит в обмен кода; - для конфиденциальных приложений секрет передаётся как service_token, а не client_secret (и обмен идёт параметрами в теле, не Basic); - профиль: POST /oauth2/user_info с client_id и access_token; права email и vkid.personal_info. Яндекс ID: - права login:email и login:info; секрет — в теле запроса обмена; - профиль: GET login.yandex.ru/info?format=json с заголовком (не Bearer). Общее: - в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name, раньше они терялись; - почта считается подтверждённой, если провайдер её отдал (отдельного флага нет, адрес приходит только по соответствующему праву) — D-083; - новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example; - тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
This commit is contained in:
@@ -42,6 +42,10 @@ LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3)
|
|||||||
# Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured.
|
# Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured.
|
||||||
# Адрес возврата для настроек приложения у провайдера:
|
# Адрес возврата для настроек приложения у провайдера:
|
||||||
# <домен>/api/v1/auth/oauth/<github|google|discord>/callback
|
# <домен>/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_ID=
|
||||||
OAUTH_GITHUB_CLIENT_SECRET=
|
OAUTH_GITHUB_CLIENT_SECRET=
|
||||||
OAUTH_GOOGLE_CLIENT_ID=
|
OAUTH_GOOGLE_CLIENT_ID=
|
||||||
|
|||||||
@@ -786,6 +786,10 @@ generate_secrets() {
|
|||||||
# OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер.
|
# OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер.
|
||||||
# Значение берём из окружения, иначе сохраняем прежнее из .env — иначе
|
# Значение берём из окружения, иначе сохраняем прежнее из .env — иначе
|
||||||
# `--reconfigure` молча выключил бы вход через провайдеров.
|
# `--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_ID="$(oauth_value OAUTH_GITHUB_CLIENT_ID)"
|
||||||
OAUTH_GITHUB_CLIENT_SECRET="$(oauth_value OAUTH_GITHUB_CLIENT_SECRET)"
|
OAUTH_GITHUB_CLIENT_SECRET="$(oauth_value OAUTH_GITHUB_CLIENT_SECRET)"
|
||||||
OAUTH_GOOGLE_CLIENT_ID="$(oauth_value OAUTH_GOOGLE_CLIENT_ID)"
|
OAUTH_GOOGLE_CLIENT_ID="$(oauth_value OAUTH_GOOGLE_CLIENT_ID)"
|
||||||
@@ -970,6 +974,13 @@ AGE_RECIPIENT=@AGE_RECIPIENT@
|
|||||||
|
|
||||||
# --- OAuth-провайдеры (Фаза 7) ---
|
# --- OAuth-провайдеры (Фаза 7) ---
|
||||||
# Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth/<id>/callback.
|
# Пусто — провайдер выключен. Адрес возврата: <домен>/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_ID=@OAUTH_GITHUB_CLIENT_ID@
|
||||||
OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@
|
OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@
|
||||||
OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@
|
OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@
|
||||||
@@ -1058,6 +1069,10 @@ LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
|
|||||||
# OAuth-провайдеры (Фаза 7): пусто — провайдер выключен. Кавычки добавляет
|
# OAuth-провайдеры (Фаза 7): пусто — провайдер выключен. Кавычки добавляет
|
||||||
# установщик только для значений со спецсимволами (docker compose разбирает
|
# установщик только для значений со спецсимволами (docker compose разбирает
|
||||||
# .env своим парсером и пустые кавычки ему не нравятся).
|
# .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_ID=$(env_quote "$OAUTH_GITHUB_CLIENT_ID")
|
||||||
OAUTH_GITHUB_CLIENT_SECRET=$(env_quote "$OAUTH_GITHUB_CLIENT_SECRET")
|
OAUTH_GITHUB_CLIENT_SECRET=$(env_quote "$OAUTH_GITHUB_CLIENT_SECRET")
|
||||||
OAUTH_GOOGLE_CLIENT_ID=$(env_quote "$OAUTH_GOOGLE_CLIENT_ID")
|
OAUTH_GOOGLE_CLIENT_ID=$(env_quote "$OAUTH_GOOGLE_CLIENT_ID")
|
||||||
|
|||||||
+256
-12
@@ -54,9 +54,21 @@ type oauthProvider struct {
|
|||||||
Name string
|
Name string
|
||||||
Endpoint oauth2.Endpoint
|
Endpoint oauth2.Endpoint
|
||||||
Scopes []string
|
Scopes []string
|
||||||
|
// PKCE включает code_challenge/code_verifier (RFC 7636). Требуется VK ID:
|
||||||
|
// верификатор выводится из подписанного state, хранить его не нужно.
|
||||||
|
PKCE bool
|
||||||
|
// DeviceIDParam — имя query-параметра, который провайдер возвращает вместе с
|
||||||
|
// кодом и требует при обмене кода на токен (у VK ID это device_id).
|
||||||
|
DeviceIDParam string
|
||||||
|
// TokenSecretParam — имя POST-параметра для секрета при обмене кода.
|
||||||
|
// Пусто — секрет уходит обычной Basic-авторизацией (как у GitHub/Google).
|
||||||
|
// У VK ID это service_token: он нужен конфиденциальным приложениям, а
|
||||||
|
// client_secret в запросе не передаётся вовсе.
|
||||||
|
TokenSecretParam string
|
||||||
// FetchProfile получает профиль по токену: email, признак подтверждения,
|
// FetchProfile получает профиль по токену: email, признак подтверждения,
|
||||||
// внешний идентификатор и логин.
|
// внешний идентификатор и логин. clientID нужен провайдерам, которые
|
||||||
FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error)
|
// запрашивают данные пользователя вместе с идентификатором приложения (VK ID).
|
||||||
|
FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token, clientID string) (oauthProfile, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthProfile — нормализованный профиль внешнего аккаунта.
|
// oauthProfile — нормализованный профиль внешнего аккаунта.
|
||||||
@@ -65,6 +77,9 @@ type oauthProfile struct {
|
|||||||
Email string
|
Email string
|
||||||
EmailVerified bool
|
EmailVerified bool
|
||||||
Login string
|
Login string
|
||||||
|
// DisplayName — имя из профиля провайдера (VK ID и Яндекс отдают его
|
||||||
|
// отдельно от логина). Пусто — берётся Login.
|
||||||
|
DisplayName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthProviderInfo — описание включённого провайдера для клиента.
|
// OAuthProviderInfo — описание включённого провайдера для клиента.
|
||||||
@@ -85,6 +100,38 @@ func oauthProviders() map[string]oauthProvider { return oauthProviderSet }
|
|||||||
//nolint:gosec // G101: здесь публичные адреса провайдеров, а не учётные данные
|
//nolint:gosec // G101: здесь публичные адреса провайдеров, а не учётные данные
|
||||||
func defaultOAuthProviders() map[string]oauthProvider {
|
func defaultOAuthProviders() map[string]oauthProvider {
|
||||||
return 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,
|
||||||
|
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": {
|
"github": {
|
||||||
ID: "github",
|
ID: "github",
|
||||||
Name: "GitHub",
|
Name: "GitHub",
|
||||||
@@ -142,7 +189,8 @@ func (s *Service) oauthConfigs() map[string]oauthProvider {
|
|||||||
func (s *Service) OAuthProviders() []OAuthProviderInfo {
|
func (s *Service) OAuthProviders() []OAuthProviderInfo {
|
||||||
configs := s.oauthConfigs()
|
configs := s.oauthConfigs()
|
||||||
// Порядок стабилен, чтобы клиент не переставлял кнопки между запросами.
|
// Порядок стабилен, чтобы клиент не переставлял кнопки между запросами.
|
||||||
order := []string{"github", "google", "discord"}
|
// VK ID и Яндекс — первыми: инстанс рассчитан на русскоязычных пользователей.
|
||||||
|
order := []string{"vk", "yandex", "github", "google", "discord"}
|
||||||
infos := make([]OAuthProviderInfo, 0, len(configs))
|
infos := make([]OAuthProviderInfo, 0, len(configs))
|
||||||
for _, id := range order {
|
for _, id := range order {
|
||||||
if provider, ok := configs[id]; ok {
|
if provider, ok := configs[id]; ok {
|
||||||
@@ -177,12 +225,58 @@ func (s *Service) OAuthAuthorizeURL(providerID, redirect string) (string, error)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
oauthConfig := s.oauthConfig(provider)
|
oauthConfig := s.oauthConfig(provider)
|
||||||
return oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline), nil
|
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 обменивает код на профиль, находит или создаёт аккаунт и
|
// OAuthCallback обменивает код на профиль, находит или создаёт аккаунт и
|
||||||
// выдаёт сессию. Возвращает также признак «аккаунт создан впервые».
|
// выдаёт сессию. Возвращает также признак «аккаунт создан впервые».
|
||||||
func (s *Service) OAuthCallback(ctx context.Context, providerID, code, state, ip, userAgent string) (*store.User, string, *store.Session, string, error) {
|
// 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) == "" {
|
if strings.TrimSpace(code) == "" {
|
||||||
return nil, "", nil, "", fmt.Errorf("%w: empty code", ErrOAuthExchange)
|
return nil, "", nil, "", fmt.Errorf("%w: empty code", ErrOAuthExchange)
|
||||||
}
|
}
|
||||||
@@ -200,11 +294,13 @@ func (s *Service) OAuthCallback(ctx context.Context, providerID, code, state, ip
|
|||||||
}
|
}
|
||||||
oauthConfig := s.oauthConfig(provider)
|
oauthConfig := s.oauthConfig(provider)
|
||||||
client := &http.Client{Timeout: oauthHTTPTimeout}
|
client := &http.Client{Timeout: oauthHTTPTimeout}
|
||||||
token, err := oauthConfig.Exchange(ctx, code)
|
credentials := s.oauth[provider.ID]
|
||||||
|
token, err := oauthConfig.Exchange(ctx, code,
|
||||||
|
s.oauthExchangeOptions(provider, state, callback, credentials)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", nil, "", fmt.Errorf("%w: exchange: %w", ErrOAuthExchange, err)
|
return nil, "", nil, "", fmt.Errorf("%w: exchange: %w", ErrOAuthExchange, err)
|
||||||
}
|
}
|
||||||
profile, err := provider.FetchProfile(ctx, client, token)
|
profile, err := provider.FetchProfile(ctx, client, token, credentials.clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", nil, "", err
|
return nil, "", nil, "", err
|
||||||
}
|
}
|
||||||
@@ -327,7 +423,10 @@ func (s *Service) createOAuthUser(ctx context.Context, email, emailIndex string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
displayName := strings.TrimSpace(profile.Login)
|
displayName := strings.TrimSpace(profile.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = strings.TrimSpace(profile.Login)
|
||||||
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = username
|
displayName = username
|
||||||
}
|
}
|
||||||
@@ -433,13 +532,23 @@ func (s *Service) signStatePayload(encoded string) string {
|
|||||||
// oauthConfig собирает oauth2.Config провайдера с адресом возврата.
|
// oauthConfig собирает oauth2.Config провайдера с адресом возврата.
|
||||||
func (s *Service) oauthConfig(provider oauthProvider) *oauth2.Config {
|
func (s *Service) oauthConfig(provider oauthProvider) *oauth2.Config {
|
||||||
credentials := s.oauth[provider.ID]
|
credentials := s.oauth[provider.ID]
|
||||||
return &oauth2.Config{
|
config := &oauth2.Config{
|
||||||
ClientID: credentials.clientID,
|
ClientID: credentials.clientID,
|
||||||
ClientSecret: credentials.clientSecret,
|
ClientSecret: credentials.clientSecret,
|
||||||
Endpoint: provider.Endpoint,
|
Endpoint: provider.Endpoint,
|
||||||
RedirectURL: strings.TrimRight(s.oauthRedirectBase, "/") + "/api/v1/auth/oauth/" + provider.ID + "/callback",
|
RedirectURL: strings.TrimRight(s.oauthRedirectBase, "/") + "/api/v1/auth/oauth/" + provider.ID + "/callback",
|
||||||
Scopes: provider.Scopes,
|
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 возвращает адрес возврата: его оператор прописывает в
|
// OAuthRedirectURL возвращает адрес возврата: его оператор прописывает в
|
||||||
@@ -486,7 +595,140 @@ func sanitizeUsername(value string) string {
|
|||||||
|
|
||||||
// --- провайдеры: получение нормализованного профиля ---
|
// --- провайдеры: получение нормализованного профиля ---
|
||||||
|
|
||||||
func fetchGitHubProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
// Адреса методов «профиль пользователя» — переменные, чтобы тесты подменяли их
|
||||||
|
// на 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 {
|
var user struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
@@ -529,7 +771,7 @@ func fetchGitHubProfile(ctx context.Context, client *http.Client, token *oauth2.
|
|||||||
return profile, nil
|
return profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
||||||
var user struct {
|
var user struct {
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -547,7 +789,7 @@ func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2.
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchDiscordProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
func fetchDiscordProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
||||||
var user struct {
|
var user struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -609,6 +851,8 @@ func oauthFromConfig(cfg config.Config, baseURL string) (map[string]oauthClientC
|
|||||||
credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret}
|
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("github", cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret)
|
||||||
add("google", cfg.OAuthGoogleClientID, cfg.OAuthGoogleClientSecret)
|
add("google", cfg.OAuthGoogleClientID, cfg.OAuthGoogleClientSecret)
|
||||||
add("discord", cfg.OAuthDiscordClientID, cfg.OAuthDiscordClientSecret)
|
add("discord", cfg.OAuthDiscordClientID, cfg.OAuthDiscordClientSecret)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server {
|
|||||||
TokenURL: server.URL + "/token",
|
TokenURL: server.URL + "/token",
|
||||||
},
|
},
|
||||||
Scopes: []string{"user:email"},
|
Scopes: []string{"user:email"},
|
||||||
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
||||||
// Провайдер получает токен так же, как в бою: через oauth2-клиент.
|
// Провайдер получает токен так же, как в бою: через oauth2-клиент.
|
||||||
authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil)
|
||||||
@@ -147,9 +147,15 @@ func TestOAuthNotConfigured(t *testing.T) {
|
|||||||
if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
|
if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
|
||||||
t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err)
|
t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err)
|
||||||
}
|
}
|
||||||
if _, err := service.OAuthAuthorizeURL("vk", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
|
if _, err := service.OAuthAuthorizeURL("ok", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
|
||||||
t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err)
|
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 {
|
if len(service.OAuthProviders()) != 0 {
|
||||||
t.Fatalf("providers = %v, want empty", service.OAuthProviders())
|
t.Fatalf("providers = %v, want empty", service.OAuthProviders())
|
||||||
}
|
}
|
||||||
@@ -206,7 +212,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent")
|
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("oauth callback: %v", err)
|
t.Fatalf("oauth callback: %v", err)
|
||||||
}
|
}
|
||||||
@@ -234,7 +240,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent")
|
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second oauth callback: %v", err)
|
t.Fatalf("second oauth callback: %v", err)
|
||||||
}
|
}
|
||||||
@@ -250,7 +256,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) {
|
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)
|
t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,7 +282,7 @@ func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent")
|
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("oauth callback: %v", err)
|
t.Fatalf("oauth callback: %v", err)
|
||||||
}
|
}
|
||||||
@@ -312,7 +318,7 @@ func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) {
|
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)
|
t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,17 +329,17 @@ func TestOAuthCallbackStateAndCodeErrors(t *testing.T) {
|
|||||||
service, _ := oauthTestService(t, oauthTestConfig())
|
service, _ := oauthTestService(t, oauthTestConfig())
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) {
|
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)
|
t.Fatalf("bad state: err = %v, want ErrOAuthState", err)
|
||||||
}
|
}
|
||||||
state, err := service.signOAuthState("github", "/app")
|
state, err := service.signOAuthState("github", "/app")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
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)
|
t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err)
|
||||||
}
|
}
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
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)
|
t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +357,7 @@ func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sign state: %v", err)
|
t.Fatalf("sign state: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) {
|
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)
|
t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,3 +456,211 @@ func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) {
|
|||||||
t.Fatal("blind indexes must be independent")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ type Config struct {
|
|||||||
RetainInterval time.Duration
|
RetainInterval time.Duration
|
||||||
// OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами
|
// OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами
|
||||||
// client id/secret, пустые значения означают «провайдер выключен».
|
// client id/secret, пустые значения означают «провайдер выключен».
|
||||||
|
OAuthVKClientID string
|
||||||
|
OAuthVKClientSecret string
|
||||||
|
OAuthYandexClientID string
|
||||||
|
OAuthYandexClientSecret string
|
||||||
OAuthGitHubClientID string
|
OAuthGitHubClientID string
|
||||||
OAuthGitHubClientSecret string
|
OAuthGitHubClientSecret string
|
||||||
OAuthGoogleClientID string
|
OAuthGoogleClientID string
|
||||||
@@ -121,6 +125,10 @@ 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", ""),
|
OAuthGitHubClientID: env("OAUTH_GITHUB_CLIENT_ID", ""),
|
||||||
OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""),
|
OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""),
|
||||||
OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""),
|
OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""),
|
||||||
@@ -206,12 +214,14 @@ func (c Config) WebAuthnEnabled() bool {
|
|||||||
|
|
||||||
// OAuthProviders перечисляет включённые провайдеры в стабильном порядке.
|
// OAuthProviders перечисляет включённые провайдеры в стабильном порядке.
|
||||||
func (c Config) OAuthProviders() []string {
|
func (c Config) OAuthProviders() []string {
|
||||||
providers := make([]string, 0, 3)
|
providers := make([]string, 0, 5)
|
||||||
for _, candidate := range []struct {
|
for _, candidate := range []struct {
|
||||||
id string
|
id string
|
||||||
client string
|
client string
|
||||||
secret string
|
secret string
|
||||||
}{
|
}{
|
||||||
|
{"vk", c.OAuthVKClientID, c.OAuthVKClientSecret},
|
||||||
|
{"yandex", c.OAuthYandexClientID, c.OAuthYandexClientSecret},
|
||||||
{"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret},
|
{"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret},
|
||||||
{"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret},
|
{"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret},
|
||||||
{"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret},
|
{"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret},
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
provider := chi.URLParam(r, "provider")
|
provider := chi.URLParam(r, "provider")
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
_, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider,
|
_, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider,
|
||||||
query.Get("code"), query.Get("state"),
|
query.Get("code"), query.Get("state"), query,
|
||||||
httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context()))
|
httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := oauthErrorCode(err)
|
code := oauthErrorCode(err)
|
||||||
|
|||||||
@@ -21,7 +21,18 @@ func TestOAuthProviderNotConfigured(t *testing.T) {
|
|||||||
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
|
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/vk/start", "")
|
// 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 {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("unknown provider = %d, want 404", rec.Code)
|
t.Fatalf("unknown provider = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user