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 с заголовком "Authorization: OAuth <токен>" (не Bearer). Общее: - в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name, раньше они терялись; - почта считается подтверждённой, если провайдер её отдал (отдельного флага нет, адрес приходит только по соответствующему праву) — D-083; - новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example; - тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
This commit is contained in:
+256
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user