Files
glchat/internal/auth/oauth.go
T
grendervill 00d86bb70c 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;
- тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
2026-09-27 17:58:04 +03:00

865 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}