diff --git a/.env.example b/.env.example index a543109..36b1b39 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,10 @@ LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3) # Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured. # Адрес возврата для настроек приложения у провайдера: # <домен>/api/v1/auth/oauth//callback +OAUTH_VK_CLIENT_ID= +OAUTH_VK_CLIENT_SECRET= +OAUTH_YANDEX_CLIENT_ID= +OAUTH_YANDEX_CLIENT_SECRET= OAUTH_GITHUB_CLIENT_ID= OAUTH_GITHUB_CLIENT_SECRET= OAUTH_GOOGLE_CLIENT_ID= diff --git a/deploy/install.sh b/deploy/install.sh index ee8736a..28a28c5 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -786,6 +786,10 @@ generate_secrets() { # 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)" @@ -970,6 +974,13 @@ AGE_RECIPIENT=@AGE_RECIPIENT@ # --- OAuth-провайдеры (Фаза 7) --- # Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth//callback. +# VK ID: id.vk.ru → «Мои приложения»; секрет — сервисный ключ доступа +# (для конфиденциального приложения он обязателен при обмене кода на токен). +OAUTH_VK_CLIENT_ID=@OAUTH_VK_CLIENT_ID@ +OAUTH_VK_CLIENT_SECRET=@OAUTH_VK_CLIENT_SECRET@ +# Яндекс ID: oauth.yandex.ru → «Создать приложение» (права login:email, login:info). +OAUTH_YANDEX_CLIENT_ID=@OAUTH_YANDEX_CLIENT_ID@ +OAUTH_YANDEX_CLIENT_SECRET=@OAUTH_YANDEX_CLIENT_SECRET@ OAUTH_GITHUB_CLIENT_ID=@OAUTH_GITHUB_CLIENT_ID@ OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@ OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@ @@ -1058,6 +1069,10 @@ 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") diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 381381a..926f636 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -54,9 +54,21 @@ type oauthProvider struct { 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 + // TokenSecretParam — имя POST-параметра для секрета при обмене кода. + // Пусто — секрет уходит обычной Basic-авторизацией (как у GitHub/Google). + // У VK ID это service_token: он нужен конфиденциальным приложениям, а + // client_secret в запросе не передаётся вовсе. + TokenSecretParam string // FetchProfile получает профиль по токену: email, признак подтверждения, - // внешний идентификатор и логин. - FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) + // внешний идентификатор и логин. clientID нужен провайдерам, которые + // запрашивают данные пользователя вместе с идентификатором приложения (VK ID). + FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token, clientID string) (oauthProfile, error) } // oauthProfile — нормализованный профиль внешнего аккаунта. @@ -65,6 +77,9 @@ type oauthProfile struct { Email string EmailVerified bool Login string + // DisplayName — имя из профиля провайдера (VK ID и Яндекс отдают его + // отдельно от логина). Пусто — берётся Login. + DisplayName string } // OAuthProviderInfo — описание включённого провайдера для клиента. @@ -85,6 +100,38 @@ 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, + 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", @@ -142,7 +189,8 @@ func (s *Service) oauthConfigs() map[string]oauthProvider { func (s *Service) OAuthProviders() []OAuthProviderInfo { configs := s.oauthConfigs() // Порядок стабилен, чтобы клиент не переставлял кнопки между запросами. - order := []string{"github", "google", "discord"} + // 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 { @@ -177,12 +225,58 @@ func (s *Service) OAuthAuthorizeURL(providerID, redirect string) (string, error) return "", err } 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 обменивает код на профиль, находит или создаёт аккаунт и // выдаёт сессию. Возвращает также признак «аккаунт создан впервые». -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) == "" { 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) 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 { 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 { return nil, "", nil, "", err } @@ -327,7 +423,10 @@ func (s *Service) createOAuthUser(ctx context.Context, email, emailIndex string, if err != nil { return nil, err } - displayName := strings.TrimSpace(profile.Login) + displayName := strings.TrimSpace(profile.DisplayName) + if displayName == "" { + displayName = strings.TrimSpace(profile.Login) + } if displayName == "" { displayName = username } @@ -433,13 +532,23 @@ func (s *Service) signStatePayload(encoded string) string { // oauthConfig собирает oauth2.Config провайдера с адресом возврата. func (s *Service) oauthConfig(provider oauthProvider) *oauth2.Config { credentials := s.oauth[provider.ID] - return &oauth2.Config{ + 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 возвращает адрес возврата: его оператор прописывает в @@ -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 { ID int64 `json:"id"` Login string `json:"login"` @@ -529,7 +771,7 @@ func fetchGitHubProfile(ctx context.Context, client *http.Client, token *oauth2. 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 { Sub string `json:"sub"` Email string `json:"email"` @@ -547,7 +789,7 @@ func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2. }, 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 { ID string `json:"id"` 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} } } + 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) diff --git a/internal/auth/oauth_internal_test.go b/internal/auth/oauth_internal_test.go index 634c66c..fd31792 100644 --- a/internal/auth/oauth_internal_test.go +++ b/internal/auth/oauth_internal_test.go @@ -94,7 +94,7 @@ func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server { TokenURL: server.URL + "/token", }, 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-клиент. authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) 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) { 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) } + // 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()) } @@ -206,7 +212,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) { if err != nil { 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 { t.Fatalf("oauth callback: %v", err) } @@ -234,7 +240,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) { if err != nil { 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 { t.Fatalf("second oauth callback: %v", err) } @@ -250,7 +256,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) { if err != nil { 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) } } @@ -276,7 +282,7 @@ func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) { if err != nil { 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 { t.Fatalf("oauth callback: %v", err) } @@ -312,7 +318,7 @@ func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) { if err != nil { 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) } } @@ -323,17 +329,17 @@ func TestOAuthCallbackStateAndCodeErrors(t *testing.T) { service, _ := oauthTestService(t, oauthTestConfig()) 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) } state, err := service.signOAuthState("github", "/app") if err != nil { 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) } - 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) } } @@ -351,7 +357,7 @@ func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) { if err != nil { 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) } } @@ -450,3 +456,211 @@ func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) { 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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8a1fa80..8e353b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,6 +64,10 @@ type Config struct { 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 @@ -121,6 +125,10 @@ func Load() (Config, error) { RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24), RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second, + OAuthVKClientID: env("OAUTH_VK_CLIENT_ID", ""), + OAuthVKClientSecret: env("OAUTH_VK_CLIENT_SECRET", ""), + OAuthYandexClientID: env("OAUTH_YANDEX_CLIENT_ID", ""), + OAuthYandexClientSecret: env("OAUTH_YANDEX_CLIENT_SECRET", ""), OAuthGitHubClientID: env("OAUTH_GITHUB_CLIENT_ID", ""), OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""), OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""), @@ -206,12 +214,14 @@ func (c Config) WebAuthnEnabled() bool { // OAuthProviders перечисляет включённые провайдеры в стабильном порядке. func (c Config) OAuthProviders() []string { - providers := make([]string, 0, 3) + 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}, diff --git a/internal/server/oauth.go b/internal/server/oauth.go index 1645c0f..df8a398 100644 --- a/internal/server/oauth.go +++ b/internal/server/oauth.go @@ -67,7 +67,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { 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.Get("code"), query.Get("state"), query, httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context())) if err != nil { code := oauthErrorCode(err) diff --git a/internal/server/oauth_test.go b/internal/server/oauth_test.go index e2aee68..93c4e19 100644 --- a/internal/server/oauth_test.go +++ b/internal/server/oauth_test.go @@ -21,7 +21,18 @@ func TestOAuthProviderNotConfigured(t *testing.T) { 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 { t.Fatalf("unknown provider = %d, want 404", rec.Code) }