2026-09-26 15:13:24 +03:00
|
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/hmac"
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"log/slog"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
|
|
|
|
|
|
|
|
"glchat/internal/config"
|
|
|
|
|
|
"glchat/internal/crypto"
|
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth-вход (AGENT.md 7.1): провайдеры включаются переменными окружения,
|
|
|
|
|
|
// привязка внешнего аккаунта идёт по подтверждённому email через blind index.
|
|
|
|
|
|
// Токены провайдера не сохраняются: они нужны только на время обмена кода.
|
|
|
|
|
|
const (
|
|
|
|
|
|
// oauthStateTTL — срок жизни подписанного state (защита от CSRF).
|
|
|
|
|
|
oauthStateTTL = 10 * time.Minute
|
|
|
|
|
|
// oauthHTTPTimeout ограничивает запросы к провайдеру.
|
|
|
|
|
|
oauthHTTPTimeout = 10 * time.Second
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
// ErrOAuthNotConfigured — провайдер выключен (нет client id/secret).
|
|
|
|
|
|
ErrOAuthNotConfigured = errors.New("oauth.provider_not_configured")
|
|
|
|
|
|
// ErrOAuthUnknownProvider — неизвестное имя провайдера.
|
|
|
|
|
|
ErrOAuthUnknownProvider = errors.New("oauth.provider_unknown")
|
|
|
|
|
|
// ErrOAuthState — state не прошёл проверку подписи или истёк.
|
|
|
|
|
|
ErrOAuthState = errors.New("oauth.state_invalid")
|
|
|
|
|
|
// ErrOAuthExchange — обмен кода или запрос профиля не удался.
|
|
|
|
|
|
ErrOAuthExchange = errors.New("oauth.exchange_failed")
|
|
|
|
|
|
// ErrOAuthEmailUnverified — провайдер не подтвердил email.
|
|
|
|
|
|
ErrOAuthEmailUnverified = errors.New("oauth.email_unverified")
|
|
|
|
|
|
// ErrOAuthEmailMissing — провайдер не отдал email.
|
|
|
|
|
|
ErrOAuthEmailMissing = errors.New("oauth.email_missing")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// oauthProvider — описание провайдера: всё, что нужно для входа.
|
|
|
|
|
|
type oauthProvider struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Endpoint oauth2.Endpoint
|
|
|
|
|
|
Scopes []string
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// 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
|
2026-09-26 15:13:24 +03:00
|
|
|
|
// FetchProfile получает профиль по токену: email, признак подтверждения,
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// внешний идентификатор и логин. clientID нужен провайдерам, которые
|
|
|
|
|
|
// запрашивают данные пользователя вместе с идентификатором приложения (VK ID).
|
|
|
|
|
|
FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token, clientID string) (oauthProfile, error)
|
2026-09-26 15:13:24 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthProfile — нормализованный профиль внешнего аккаунта.
|
|
|
|
|
|
type oauthProfile struct {
|
|
|
|
|
|
Subject string
|
|
|
|
|
|
Email string
|
|
|
|
|
|
EmailVerified bool
|
|
|
|
|
|
Login string
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// DisplayName — имя из профиля провайдера (VK ID и Яндекс отдают его
|
|
|
|
|
|
// отдельно от логина). Пусто — берётся Login.
|
|
|
|
|
|
DisplayName string
|
2026-09-26 15:13:24 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthProviderInfo — описание включённого провайдера для клиента.
|
|
|
|
|
|
type OAuthProviderInfo struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthProviderSet — каталог провайдеров. Переменная, а не функция, чтобы
|
|
|
|
|
|
// тесты подменяли адреса провайдеров на httptest-сервер (в бою — значения по
|
|
|
|
|
|
// умолчанию, см. defaultOAuthProviders).
|
|
|
|
|
|
var oauthProviderSet = defaultOAuthProviders()
|
|
|
|
|
|
|
|
|
|
|
|
// oauthProviders возвращает каталог поддерживаемых провайдеров (AGENT.md 7.1:
|
|
|
|
|
|
// Google, GitHub, Discord). Новый провайдер добавляется одной записью.
|
|
|
|
|
|
func oauthProviders() map[string]oauthProvider { return oauthProviderSet }
|
|
|
|
|
|
|
|
|
|
|
|
//nolint:gosec // G101: здесь публичные адреса провайдеров, а не учётные данные
|
|
|
|
|
|
func defaultOAuthProviders() map[string]oauthProvider {
|
|
|
|
|
|
return map[string]oauthProvider{
|
2026-09-27 17:57:48 +03:00
|
|
|
|
"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,
|
|
|
|
|
|
},
|
2026-09-26 15:13:24 +03:00
|
|
|
|
"github": {
|
|
|
|
|
|
ID: "github",
|
|
|
|
|
|
Name: "GitHub",
|
|
|
|
|
|
Endpoint: oauth2.Endpoint{
|
|
|
|
|
|
AuthURL: "https://github.com/login/oauth/authorize",
|
|
|
|
|
|
TokenURL: "https://github.com/login/oauth/access_token",
|
|
|
|
|
|
},
|
|
|
|
|
|
Scopes: []string{"read:user", "user:email"},
|
|
|
|
|
|
FetchProfile: fetchGitHubProfile,
|
|
|
|
|
|
},
|
|
|
|
|
|
"google": {
|
|
|
|
|
|
ID: "google",
|
|
|
|
|
|
Name: "Google",
|
|
|
|
|
|
Endpoint: oauth2.Endpoint{
|
|
|
|
|
|
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
|
|
|
|
TokenURL: "https://oauth2.googleapis.com/token",
|
|
|
|
|
|
},
|
|
|
|
|
|
Scopes: []string{"openid", "email", "profile"},
|
|
|
|
|
|
FetchProfile: fetchGoogleProfile,
|
|
|
|
|
|
},
|
|
|
|
|
|
"discord": {
|
|
|
|
|
|
ID: "discord",
|
|
|
|
|
|
Name: "Discord",
|
|
|
|
|
|
Endpoint: oauth2.Endpoint{
|
|
|
|
|
|
AuthURL: "https://discord.com/oauth2/authorize",
|
|
|
|
|
|
TokenURL: "https://discord.com/api/oauth2/token",
|
|
|
|
|
|
},
|
|
|
|
|
|
Scopes: []string{"identify", "email"},
|
|
|
|
|
|
FetchProfile: fetchDiscordProfile,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthConfigs — включённые провайдеры: id → параметры клиента.
|
|
|
|
|
|
func (s *Service) oauthConfigs() map[string]oauthProvider {
|
|
|
|
|
|
providers := oauthProviders()
|
|
|
|
|
|
enabled := make(map[string]oauthProvider, len(providers))
|
|
|
|
|
|
for id, provider := range providers {
|
|
|
|
|
|
credentials, ok := s.oauth[id]
|
|
|
|
|
|
if !ok || credentials.clientID == "" || credentials.clientSecret == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
provider.Scopes = append([]string{}, provider.Scopes...)
|
|
|
|
|
|
enabled[id] = provider
|
|
|
|
|
|
if id == "google" {
|
|
|
|
|
|
// Google требует offline-доступ только при необходимости refresh;
|
|
|
|
|
|
// нам достаточно одноразового обмена кода.
|
|
|
|
|
|
enabled[id] = provider
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return enabled
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthProviders перечисляет включённые провайдеры для клиента.
|
|
|
|
|
|
func (s *Service) OAuthProviders() []OAuthProviderInfo {
|
|
|
|
|
|
configs := s.oauthConfigs()
|
|
|
|
|
|
// Порядок стабилен, чтобы клиент не переставлял кнопки между запросами.
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// VK ID и Яндекс — первыми: инстанс рассчитан на русскоязычных пользователей.
|
|
|
|
|
|
order := []string{"vk", "yandex", "github", "google", "discord"}
|
2026-09-26 15:13:24 +03:00
|
|
|
|
infos := make([]OAuthProviderInfo, 0, len(configs))
|
|
|
|
|
|
for _, id := range order {
|
|
|
|
|
|
if provider, ok := configs[id]; ok {
|
|
|
|
|
|
infos = append(infos, OAuthProviderInfo{ID: provider.ID, Name: provider.Name})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return infos
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthEnabled сообщает, настроен ли хотя бы один провайдер.
|
|
|
|
|
|
func (s *Service) OAuthEnabled() bool { return len(s.oauthConfigs()) > 0 }
|
|
|
|
|
|
|
|
|
|
|
|
// oauthClientCredentials — client id/secret конкретного провайдера.
|
|
|
|
|
|
type oauthClientCredentials struct {
|
|
|
|
|
|
clientID string
|
|
|
|
|
|
clientSecret string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthAuthorizeURL строит ссылку на провайдера с подписанным state.
|
|
|
|
|
|
// redirect — куда вернуть пользователя внутри клиента после входа.
|
|
|
|
|
|
func (s *Service) OAuthAuthorizeURL(providerID, redirect string) (string, error) {
|
|
|
|
|
|
configs := s.oauthConfigs()
|
|
|
|
|
|
provider, ok := configs[strings.ToLower(strings.TrimSpace(providerID))]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
if _, known := oauthProviders()[strings.ToLower(strings.TrimSpace(providerID))]; known {
|
|
|
|
|
|
return "", ErrOAuthNotConfigured
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", ErrOAuthUnknownProvider
|
|
|
|
|
|
}
|
|
|
|
|
|
state, err := s.signOAuthState(provider.ID, redirect)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
oauthConfig := s.oauthConfig(provider)
|
2026-09-27 17:57:48 +03:00
|
|
|
|
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
|
2026-09-26 15:13:24 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthCallback обменивает код на профиль, находит или создаёт аккаунт и
|
|
|
|
|
|
// выдаёт сессию. Возвращает также признак «аккаунт создан впервые».
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// 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) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if strings.TrimSpace(code) == "" {
|
|
|
|
|
|
return nil, "", nil, "", fmt.Errorf("%w: empty code", ErrOAuthExchange)
|
|
|
|
|
|
}
|
|
|
|
|
|
redirect, err := s.verifyOAuthState(providerID, state)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
configs := s.oauthConfigs()
|
|
|
|
|
|
provider, ok := configs[strings.ToLower(strings.TrimSpace(providerID))]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
if _, known := oauthProviders()[strings.ToLower(strings.TrimSpace(providerID))]; known {
|
|
|
|
|
|
return nil, "", nil, "", ErrOAuthNotConfigured
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, "", nil, "", ErrOAuthUnknownProvider
|
|
|
|
|
|
}
|
|
|
|
|
|
oauthConfig := s.oauthConfig(provider)
|
|
|
|
|
|
client := &http.Client{Timeout: oauthHTTPTimeout}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
credentials := s.oauth[provider.ID]
|
|
|
|
|
|
token, err := oauthConfig.Exchange(ctx, code,
|
|
|
|
|
|
s.oauthExchangeOptions(provider, state, callback, credentials)...)
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", nil, "", fmt.Errorf("%w: exchange: %w", ErrOAuthExchange, err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
profile, err := provider.FetchProfile(ctx, client, token, credentials.clientID)
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if profile.Email == "" {
|
|
|
|
|
|
return nil, "", nil, "", ErrOAuthEmailMissing
|
|
|
|
|
|
}
|
|
|
|
|
|
if !profile.EmailVerified {
|
|
|
|
|
|
return nil, "", nil, "", ErrOAuthEmailUnverified
|
|
|
|
|
|
}
|
|
|
|
|
|
email := crypto.NormalizeEmail(profile.Email)
|
|
|
|
|
|
emailIndex := s.masterKey.BlindIndex(email)
|
|
|
|
|
|
subjectIndex := s.masterKey.BlindIndex(provider.ID + ":" + profile.Subject)
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Уже привязанный внешний аккаунт — вход без создания новых записей.
|
|
|
|
|
|
if account, lookupErr := s.store.OAuthAccountBySubject(ctx, provider.ID, subjectIndex); lookupErr == nil {
|
|
|
|
|
|
user, userErr := s.store.GetUser(ctx, account.UserID)
|
|
|
|
|
|
if userErr != nil {
|
|
|
|
|
|
return nil, "", nil, "", userErr
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.oauthBanCheck(ctx, user, ip, userAgent); err != nil {
|
|
|
|
|
|
return nil, "", nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.store.TouchOAuthAccount(ctx, account.ID); err != nil {
|
|
|
|
|
|
s.logger.WarnContext(ctx, "не удалось обновить время входа OAuth-аккаунта", slog.Any("error", err))
|
|
|
|
|
|
}
|
|
|
|
|
|
sessionToken, session, loginErr := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_login")
|
|
|
|
|
|
return user, sessionToken, session, redirect, loginErr
|
|
|
|
|
|
} else if !errors.Is(lookupErr, store.ErrNotFound) {
|
|
|
|
|
|
return nil, "", nil, "", lookupErr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Аккаунт с таким email — привязываем провайдера к нему.
|
|
|
|
|
|
if user, lookupErr := s.store.GetUserByEmailIndex(ctx, emailIndex); lookupErr == nil {
|
|
|
|
|
|
if err := s.oauthBanCheck(ctx, user, ip, userAgent); err != nil {
|
|
|
|
|
|
return nil, "", nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
account, linkErr := s.store.LinkOAuthAccount(ctx, user.ID, provider.ID, subjectIndex, emailIndex)
|
|
|
|
|
|
if linkErr != nil {
|
|
|
|
|
|
return nil, "", nil, "", linkErr
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.store.TouchOAuthAccount(ctx, account.ID); err != nil {
|
|
|
|
|
|
s.logger.WarnContext(ctx, "не удалось обновить время входа OAuth-аккаунта", slog.Any("error", err))
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "oauth_linked", ip, userAgent,
|
|
|
|
|
|
`{"provider":`+quoteJSON(provider.ID)+`}`)
|
|
|
|
|
|
if err := s.store.RecordAudit(ctx, store.AuditParams{
|
|
|
|
|
|
ActorID: &user.ID,
|
|
|
|
|
|
Action: "user.oauth_link",
|
|
|
|
|
|
TargetType: "user",
|
|
|
|
|
|
TargetID: &user.ID,
|
|
|
|
|
|
Changes: map[string]any{"provider": provider.ID},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
s.logger.WarnContext(ctx, "не удалось записать привязку OAuth в аудит", slog.Any("error", err))
|
|
|
|
|
|
}
|
|
|
|
|
|
sessionToken, session, loginErr := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_linked")
|
|
|
|
|
|
return user, sessionToken, session, redirect, loginErr
|
|
|
|
|
|
} else if !errors.Is(lookupErr, store.ErrNotFound) {
|
|
|
|
|
|
return nil, "", nil, "", lookupErr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Новый аккаунт: регистрация должна быть разрешена (флаг инстанса).
|
|
|
|
|
|
settings, err := s.settings.InstanceSettings(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", nil, "", fmt.Errorf("load instance settings: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !settings.RegistrationEnabled {
|
|
|
|
|
|
return nil, "", nil, "", ErrRegistrationOff
|
|
|
|
|
|
}
|
|
|
|
|
|
user, err := s.createOAuthUser(ctx, email, emailIndex, profile, ip, userAgent)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := s.store.LinkOAuthAccount(ctx, user.ID, provider.ID, subjectIndex, emailIndex); err != nil {
|
|
|
|
|
|
return nil, "", nil, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
sessionToken, session, err := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_register")
|
|
|
|
|
|
return user, sessionToken, session, redirect, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// finishOAuthLogin создаёт сессию и пишет событие безопасности.
|
|
|
|
|
|
func (s *Service) finishOAuthLogin(ctx context.Context, user *store.User, ip, userAgent, event string) (string, *store.Session, error) {
|
|
|
|
|
|
token, session, err := s.createSession(ctx, user.ID, userAgent, ip)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, event, ip, userAgent, "")
|
|
|
|
|
|
return token, session, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthBanCheck проверяет глобальный бан инстанса до выдачи сессии (7.18).
|
|
|
|
|
|
func (s *Service) oauthBanCheck(ctx context.Context, user *store.User, ip, userAgent string) error {
|
|
|
|
|
|
banned, err := s.store.IsInstanceBanned(ctx, user.ID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if banned {
|
|
|
|
|
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", ip, userAgent, `{"method":"oauth"}`)
|
|
|
|
|
|
return ErrUserBanned
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// createOAuthUser заводит аккаунт по данным провайдера: логин из профиля,
|
|
|
|
|
|
// пароль — случайный (вход только через провайдера), email шифруется.
|
|
|
|
|
|
func (s *Service) createOAuthUser(ctx context.Context, email, emailIndex string, profile oauthProfile, ip, userAgent string) (*store.User, error) {
|
|
|
|
|
|
username, err := s.uniqueUsername(ctx, profile.Login, email)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
randomPassword, _, err := crypto.NewSessionToken()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
passwordHash, err := s.hasher.Hash(randomPassword)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
userID := s.store.NextID()
|
|
|
|
|
|
encrypted, err := s.encryptEmail(userID, email)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
displayName := strings.TrimSpace(profile.DisplayName)
|
|
|
|
|
|
if displayName == "" {
|
|
|
|
|
|
displayName = strings.TrimSpace(profile.Login)
|
|
|
|
|
|
}
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if displayName == "" {
|
|
|
|
|
|
displayName = username
|
|
|
|
|
|
}
|
|
|
|
|
|
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
|
|
|
|
|
ID: userID,
|
|
|
|
|
|
Username: username,
|
|
|
|
|
|
DisplayName: defaultDisplayName(displayName, username),
|
|
|
|
|
|
EmailEnc: encrypted,
|
|
|
|
|
|
EmailIndex: emailIndex,
|
|
|
|
|
|
PasswordHash: passwordHash,
|
|
|
|
|
|
Locale: "ru",
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "oauth_register", ip, userAgent, "")
|
|
|
|
|
|
return user, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// uniqueUsername подбирает свободный username: из логина провайдера, иначе из
|
|
|
|
|
|
// локальной части email; суффикс добавляется только при конфликте.
|
|
|
|
|
|
func (s *Service) uniqueUsername(ctx context.Context, login, email string) (string, error) {
|
|
|
|
|
|
candidate := sanitizeUsername(login)
|
|
|
|
|
|
if candidate == "" {
|
|
|
|
|
|
local := email
|
|
|
|
|
|
if at := strings.Index(email, "@"); at > 0 {
|
|
|
|
|
|
local = email[:at]
|
|
|
|
|
|
}
|
|
|
|
|
|
candidate = sanitizeUsername(local)
|
|
|
|
|
|
}
|
|
|
|
|
|
if candidate == "" {
|
|
|
|
|
|
candidate = "user"
|
|
|
|
|
|
}
|
|
|
|
|
|
for attempt := 0; attempt < 20; attempt++ {
|
|
|
|
|
|
value := candidate
|
|
|
|
|
|
if attempt > 0 {
|
|
|
|
|
|
suffix := strconv.Itoa(attempt + 1)
|
|
|
|
|
|
limit := 32 - len(suffix)
|
|
|
|
|
|
if len(value) > limit {
|
|
|
|
|
|
value = value[:limit]
|
|
|
|
|
|
}
|
|
|
|
|
|
value += suffix
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := s.store.GetUserByUsername(ctx, value); errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
|
return value, nil
|
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", fmt.Errorf("cannot pick a free username for oauth account")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// signOAuthState подписывает state ключом сессий: хранить его в БД не нужно,
|
|
|
|
|
|
// подпись защищает от подмены (AGENT.md 9.2).
|
|
|
|
|
|
func (s *Service) signOAuthState(providerID, redirect string) (string, error) {
|
|
|
|
|
|
payload, err := json.Marshal(map[string]string{
|
|
|
|
|
|
"provider": providerID,
|
|
|
|
|
|
"redirect": safeRedirect(redirect),
|
|
|
|
|
|
"issued": strconv.FormatInt(time.Now().UTC().Unix(), 10),
|
|
|
|
|
|
"nonce": crypto.HashToken(strconv.FormatInt(time.Now().UTC().UnixNano(), 10))[:16],
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
|
|
|
|
|
return encoded + "." + s.signStatePayload(encoded), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// verifyOAuthState проверяет подпись, срок и провайдера, возвращая redirect.
|
|
|
|
|
|
func (s *Service) verifyOAuthState(providerID, state string) (string, error) {
|
|
|
|
|
|
parts := strings.Split(state, ".")
|
|
|
|
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
|
|
|
|
return "", ErrOAuthState
|
|
|
|
|
|
}
|
|
|
|
|
|
expected := s.signStatePayload(parts[0])
|
|
|
|
|
|
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
|
|
|
|
|
return "", ErrOAuthState
|
|
|
|
|
|
}
|
|
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", ErrOAuthState
|
|
|
|
|
|
}
|
|
|
|
|
|
var values map[string]string
|
|
|
|
|
|
if err := json.Unmarshal(payload, &values); err != nil {
|
|
|
|
|
|
return "", ErrOAuthState
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.EqualFold(values["provider"], providerID) {
|
|
|
|
|
|
return "", ErrOAuthState
|
|
|
|
|
|
}
|
|
|
|
|
|
issued, err := strconv.ParseInt(values["issued"], 10, 64)
|
|
|
|
|
|
if err != nil || time.Since(time.Unix(issued, 0).UTC()) > oauthStateTTL {
|
|
|
|
|
|
return "", ErrOAuthState
|
|
|
|
|
|
}
|
|
|
|
|
|
return safeRedirect(values["redirect"]), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) signStatePayload(encoded string) string {
|
|
|
|
|
|
mac := hmac.New(sha256.New, []byte(s.stateKey))
|
|
|
|
|
|
mac.Write([]byte("oauth-state:" + encoded))
|
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthConfig собирает oauth2.Config провайдера с адресом возврата.
|
|
|
|
|
|
func (s *Service) oauthConfig(provider oauthProvider) *oauth2.Config {
|
|
|
|
|
|
credentials := s.oauth[provider.ID]
|
2026-09-27 17:57:48 +03:00
|
|
|
|
config := &oauth2.Config{
|
2026-09-26 15:13:24 +03:00
|
|
|
|
ClientID: credentials.clientID,
|
|
|
|
|
|
ClientSecret: credentials.clientSecret,
|
|
|
|
|
|
Endpoint: provider.Endpoint,
|
|
|
|
|
|
RedirectURL: strings.TrimRight(s.oauthRedirectBase, "/") + "/api/v1/auth/oauth/" + provider.ID + "/callback",
|
|
|
|
|
|
Scopes: provider.Scopes,
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
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
|
2026-09-26 15:13:24 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuthRedirectURL возвращает адрес возврата: его оператор прописывает в
|
|
|
|
|
|
// настройках приложения у провайдера.
|
|
|
|
|
|
func (s *Service) OAuthRedirectURL() string {
|
|
|
|
|
|
return s.oauthRedirectBase + "/api/v1/auth/oauth/{provider}/callback"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// safeRedirect пропускает только внутренние пути клиента: открытый редирект
|
|
|
|
|
|
// после входа недопустим (AGENT.md 9.7).
|
|
|
|
|
|
func safeRedirect(value string) string {
|
|
|
|
|
|
if value == "" || !strings.HasPrefix(value, "/") {
|
|
|
|
|
|
return "/app"
|
|
|
|
|
|
}
|
|
|
|
|
|
// `//host` и `/\host` браузеры понимают как чужой хост: оставляем только
|
|
|
|
|
|
// внутренние пути клиента (AGENT.md 9.7).
|
|
|
|
|
|
if strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\") {
|
|
|
|
|
|
return "/app"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// sanitizeUsername приводит логин провайдера к правилам glchat (7.1).
|
|
|
|
|
|
func sanitizeUsername(value string) string {
|
|
|
|
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
|
|
|
|
var builder strings.Builder
|
|
|
|
|
|
for _, r := range value {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '.':
|
|
|
|
|
|
builder.WriteRune(r)
|
|
|
|
|
|
case r == '-' || r == ' ':
|
|
|
|
|
|
builder.WriteByte('_')
|
|
|
|
|
|
}
|
|
|
|
|
|
if builder.Len() >= 32 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
result := strings.Trim(builder.String(), "._")
|
|
|
|
|
|
if len(result) < 2 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- провайдеры: получение нормализованного профиля ---
|
|
|
|
|
|
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// Адреса методов «профиль пользователя» — переменные, чтобы тесты подменяли их
|
|
|
|
|
|
// на 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) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
var user struct {
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
Login string `json:"login"`
|
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := oauthGetJSON(ctx, client, token, "https://api.github.com/user", &user); err != nil {
|
|
|
|
|
|
return oauthProfile{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
var emails []struct {
|
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
|
Primary bool `json:"primary"`
|
|
|
|
|
|
Verified bool `json:"verified"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := oauthGetJSON(ctx, client, token, "https://api.github.com/user/emails", &emails); err != nil {
|
|
|
|
|
|
return oauthProfile{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
profile := oauthProfile{Subject: strconv.FormatInt(user.ID, 10), Login: user.Login}
|
|
|
|
|
|
for _, candidate := range emails {
|
|
|
|
|
|
// Берём только подтверждённый адрес: иначе чужой email в профиле GitHub
|
|
|
|
|
|
// позволил бы войти в чужой аккаунт glchat.
|
|
|
|
|
|
if candidate.Primary && candidate.Verified && candidate.Email != "" {
|
|
|
|
|
|
profile.Email = candidate.Email
|
|
|
|
|
|
profile.EmailVerified = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if profile.Email == "" {
|
|
|
|
|
|
for _, candidate := range emails {
|
|
|
|
|
|
if candidate.Verified && candidate.Email != "" {
|
|
|
|
|
|
profile.Email = candidate.Email
|
|
|
|
|
|
profile.EmailVerified = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if profile.Email == "" && user.Email != "" {
|
|
|
|
|
|
// Публичный email из профиля GitHub подтверждённым не считаем.
|
|
|
|
|
|
profile.Email = user.Email
|
|
|
|
|
|
}
|
|
|
|
|
|
return profile, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:57:48 +03:00
|
|
|
|
func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
var user struct {
|
|
|
|
|
|
Sub string `json:"sub"`
|
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
|
EmailVerified bool `json:"email_verified"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := oauthGetJSON(ctx, client, token, "https://openidconnect.googleapis.com/v1/userinfo", &user); err != nil {
|
|
|
|
|
|
return oauthProfile{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return oauthProfile{
|
|
|
|
|
|
Subject: user.Sub,
|
|
|
|
|
|
Email: user.Email,
|
|
|
|
|
|
EmailVerified: user.EmailVerified,
|
|
|
|
|
|
Login: user.Name,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 17:57:48 +03:00
|
|
|
|
func fetchDiscordProfile(ctx context.Context, client *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
var user struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
|
GlobalName string `json:"global_name"`
|
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
|
Verified bool `json:"verified"`
|
|
|
|
|
|
EmailVerified bool `json:"email_verified"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := oauthGetJSON(ctx, client, token, "https://discord.com/api/users/@me", &user); err != nil {
|
|
|
|
|
|
return oauthProfile{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
login := user.GlobalName
|
|
|
|
|
|
if login == "" {
|
|
|
|
|
|
login = user.Username
|
|
|
|
|
|
}
|
|
|
|
|
|
return oauthProfile{
|
|
|
|
|
|
Subject: user.ID,
|
|
|
|
|
|
Email: user.Email,
|
|
|
|
|
|
EmailVerified: user.Verified || user.EmailVerified,
|
|
|
|
|
|
Login: login,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthGetJSON выполняет GET к API провайдера с токеном доступа.
|
|
|
|
|
|
func oauthGetJSON(ctx context.Context, client *http.Client, token *oauth2.Token, endpoint string, dst any) error {
|
|
|
|
|
|
if _, err := url.Parse(endpoint); err != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: bad endpoint", ErrOAuthExchange)
|
|
|
|
|
|
}
|
|
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: build request: %w", ErrOAuthExchange, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
|
|
|
|
request.Header.Set("User-Agent", "glchat")
|
|
|
|
|
|
token.SetAuthHeader(request)
|
|
|
|
|
|
response, err := client.Do(request)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: request: %w", ErrOAuthExchange, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
|
|
|
|
return fmt.Errorf("%w: provider returned %d", ErrOAuthExchange, response.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: read body: %w", ErrOAuthExchange, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(body, dst); err != nil {
|
|
|
|
|
|
return fmt.Errorf("%w: decode profile: %w", ErrOAuthExchange, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthFromConfig собирает включённые провайдеры из конфигурации инстанса.
|
|
|
|
|
|
func oauthFromConfig(cfg config.Config, baseURL string) (map[string]oauthClientCredentials, string) {
|
|
|
|
|
|
credentials := map[string]oauthClientCredentials{}
|
|
|
|
|
|
add := func(id, clientID, clientSecret string) {
|
|
|
|
|
|
if clientID != "" && clientSecret != "" {
|
|
|
|
|
|
credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
add("vk", cfg.OAuthVKClientID, cfg.OAuthVKClientSecret)
|
|
|
|
|
|
add("yandex", cfg.OAuthYandexClientID, cfg.OAuthYandexClientSecret)
|
2026-09-26 15:13:24 +03:00
|
|
|
|
add("github", cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret)
|
|
|
|
|
|
add("google", cfg.OAuthGoogleClientID, cfg.OAuthGoogleClientSecret)
|
|
|
|
|
|
add("discord", cfg.OAuthDiscordClientID, cfg.OAuthDiscordClientSecret)
|
|
|
|
|
|
redirectBase := strings.TrimSpace(cfg.OAuthRedirectBase)
|
|
|
|
|
|
if redirectBase == "" {
|
|
|
|
|
|
redirectBase = baseURL
|
|
|
|
|
|
}
|
|
|
|
|
|
return credentials, redirectBase
|
|
|
|
|
|
}
|