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