feat(auth): вход через OAuth-провайдеры (GitHub, Google, Discord)

Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.

Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.

Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.

Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
This commit is contained in:
2026-09-26 15:13:24 +03:00
parent cd1d662572
commit cebce0ae3b
23 changed files with 1721 additions and 12 deletions
+620
View File
@@ -0,0 +1,620 @@
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
// FetchProfile получает профиль по токену: email, признак подтверждения,
// внешний идентификатор и логин.
FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error)
}
// oauthProfile — нормализованный профиль внешнего аккаунта.
type oauthProfile struct {
Subject string
Email string
EmailVerified bool
Login 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{
"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()
// Порядок стабилен, чтобы клиент не переставлял кнопки между запросами.
order := []string{"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)
return oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline), nil
}
// OAuthCallback обменивает код на профиль, находит или создаёт аккаунт и
// выдаёт сессию. Возвращает также признак «аккаунт создан впервые».
func (s *Service) OAuthCallback(ctx context.Context, providerID, code, state, 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}
token, err := oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, "", nil, "", fmt.Errorf("%w: exchange: %w", ErrOAuthExchange, err)
}
profile, err := provider.FetchProfile(ctx, client, token)
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.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]
return &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,
}
}
// 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
}
// --- провайдеры: получение нормализованного профиля ---
func fetchGitHubProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (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) (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) (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("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
}