diff --git a/.env.example b/.env.example index 898d984..c9ad3ed 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,19 @@ TOTP_ENCRYPTION_KEY= # шифрование TOTP-секрет WEBHOOK_SECRET= # подпись вебхуков LIVEKIT_API_KEY= # ключ LiveKit (фаза 3) LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3) +# --- OAuth-провайдеры (Фаза 7): вход через внешние сервисы --- +# Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured. +# Адрес возврата для настроек приложения у провайдера: +# <домен>/api/v1/auth/oauth//callback +OAUTH_GITHUB_CLIENT_ID= +OAUTH_GITHUB_CLIENT_SECRET= +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= +OAUTH_DISCORD_CLIENT_ID= +OAUTH_DISCORD_CLIENT_SECRET= +# Необязательно: внешний адрес инстанса для redirect_uri (пусто — берётся из DOMAIN). +OAUTH_REDIRECT_BASE= + AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение) BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path diff --git a/NOTICE b/NOTICE index 691fc8b..f8f0246 100644 --- a/NOTICE +++ b/NOTICE @@ -37,6 +37,7 @@ Copyright (C) 2026 glchat contributors. | [i18next](https://www.i18next.com) | MIT | Локализация ru/en | | [age](https://age-encryption.org) (filippo.io/age) | BSD-3-Clause | Шифрование бэкапов | | [go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) | BSD-3-Clause | Passkeys (WebAuthn): регистрация и вход по ключу (Фаза 7) | +| [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) | BSD-3-Clause | OAuth2-провайдеры входа: GitHub, Google, Discord (Фаза 7) | | [fxamacker/cbor](https://github.com/fxamacker/cbor) | MIT | CBOR для WebAuthn (транзитивная зависимость go-webauthn) | Полный список Go-зависимостей с версиями — `go.mod`/`go.sum`; JS-зависимостей — diff --git a/deploy/install.sh b/deploy/install.sh index 5b73d42..6f270fe 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -636,6 +636,20 @@ generate_secrets() { # расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index, # LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие # данные нечитаемыми, поэтому она возможна только явным --rotate-secrets. + # oauth_value отдаёт значение OAuth-настройки: сначала из окружения + # (оператор задал при запуске), затем из существующего .env. + oauth_value() { + local key="$1" + local from_env="${!key:-}" + if [ -n "$from_env" ]; then + printf '%s' "$from_env" + return + fi + if [ "$env_existing" = "1" ]; then + env_get "$key" || true + fi + } + secret_or_new() { local key="$1" generator="${2:-rand_hex}" local current="" @@ -669,6 +683,17 @@ generate_secrets() { LIVEKIT_API_KEY="$(secret_or_new LIVEKIT_API_KEY rand_hex)" LIVEKIT_API_SECRET="$(secret_or_new LIVEKIT_API_SECRET rand_hex)" + # OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер. + # Значение берём из окружения, иначе сохраняем прежнее из .env — иначе + # `--reconfigure` молча выключил бы вход через провайдеров. + OAUTH_GITHUB_CLIENT_ID="$(oauth_value OAUTH_GITHUB_CLIENT_ID)" + OAUTH_GITHUB_CLIENT_SECRET="$(oauth_value OAUTH_GITHUB_CLIENT_SECRET)" + OAUTH_GOOGLE_CLIENT_ID="$(oauth_value OAUTH_GOOGLE_CLIENT_ID)" + OAUTH_GOOGLE_CLIENT_SECRET="$(oauth_value OAUTH_GOOGLE_CLIENT_SECRET)" + OAUTH_DISCORD_CLIENT_ID="$(oauth_value OAUTH_DISCORD_CLIENT_ID)" + OAUTH_DISCORD_CLIENT_SECRET="$(oauth_value OAUTH_DISCORD_CLIENT_SECRET)" + OAUTH_REDIRECT_BASE="$(oauth_value OAUTH_REDIRECT_BASE)" + if [ "$DRY_RUN" = "1" ]; then log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)" ok "секреты подготовлены (dry-run)" @@ -830,6 +855,17 @@ BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@ BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@ AGE_RECIPIENT=@AGE_RECIPIENT@ +# --- OAuth-провайдеры (Фаза 7) --- +# Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth//callback. +OAUTH_GITHUB_CLIENT_ID=@OAUTH_GITHUB_CLIENT_ID@ +OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@ +OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@ +OAUTH_GOOGLE_CLIENT_SECRET=@OAUTH_GOOGLE_CLIENT_SECRET@ +OAUTH_DISCORD_CLIENT_ID=@OAUTH_DISCORD_CLIENT_ID@ +OAUTH_DISCORD_CLIENT_SECRET=@OAUTH_DISCORD_CLIENT_SECRET@ +# Необязательно: внешний адрес инстанса для redirect_uri (пусто — из домена). +OAUTH_REDIRECT_BASE=@OAUTH_REDIRECT_BASE@ + # --- функции --- REGISTRATION_ENABLED=@REGISTRATION_ENABLED@ ANTI_BOT_ENABLED=@ANTI_BOT_ENABLED@ @@ -899,6 +935,16 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY} LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL} +# OAuth-провайдеры (Фаза 7): пусто — провайдер выключен. Кавычки обязательны: +# секреты провайдеров содержат символы, значимые для оболочки. +OAUTH_GITHUB_CLIENT_ID="${OAUTH_GITHUB_CLIENT_ID:-}" +OAUTH_GITHUB_CLIENT_SECRET="${OAUTH_GITHUB_CLIENT_SECRET:-}" +OAUTH_GOOGLE_CLIENT_ID="${OAUTH_GOOGLE_CLIENT_ID:-}" +OAUTH_GOOGLE_CLIENT_SECRET="${OAUTH_GOOGLE_CLIENT_SECRET:-}" +OAUTH_DISCORD_CLIENT_ID="${OAUTH_DISCORD_CLIENT_ID:-}" +OAUTH_DISCORD_CLIENT_SECRET="${OAUTH_DISCORD_CLIENT_SECRET:-}" +OAUTH_REDIRECT_BASE="${OAUTH_REDIRECT_BASE:-}" + # Offsite-бэкапы: цель и срок хранения задаются вручную после установки. BACKUP_OFFSITE_TARGET=${BACKUP_OFFSITE_TARGET:-} BACKUP_OFFSITE_KEEP_DAYS=${BACKUP_OFFSITE_KEEP_DAYS:-} diff --git a/go.mod b/go.mod index 0fe1109..91f8e5c 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pquerna/otp v1.5.0 github.com/pressly/goose/v3 v3.28.0 golang.org/x/crypto v0.57.0 + golang.org/x/oauth2 v0.37.0 ) require ( diff --git a/go.sum b/go.sum index 2e8c83f..d72725e 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..381381a --- /dev/null +++ b/internal/auth/oauth.go @@ -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 +} diff --git a/internal/auth/oauth_internal_test.go b/internal/auth/oauth_internal_test.go new file mode 100644 index 0000000..634c66c --- /dev/null +++ b/internal/auth/oauth_internal_test.go @@ -0,0 +1,452 @@ +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" + + "glchat/internal/config" + "glchat/internal/crypto" + "glchat/internal/database" + "glchat/internal/store" +) + +// oauthTestConfig — конфиг тестового сервиса с включённым GitHub-провайдером. +func oauthTestConfig() config.Config { + return config.Config{ + Domain: "gl.mhspx.su", + InstanceName: "glchat", + SessionPepper: "unit-test-session-pepper", + MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + TOTPEncryptionKey: "unit-test-totp-key", + SessionTTLHours: 24, + // Низкие параметры Argon2id: тесты не должны работать секундами. + Argon2MemoryKiB: 1024, + Argon2Iterations: 1, + Argon2Parallelism: 1, + OAuthGitHubClientID: "client-id", + OAuthGitHubClientSecret: "client-secret", + } +} + +// oauthTestBaseConfig — конфиг без OAuth: проверяем выключенные провайдеры. +func oauthTestBaseConfig() config.Config { + cfg := oauthTestConfig() + cfg.OAuthGitHubClientID = "" + cfg.OAuthGitHubClientSecret = "" + return cfg +} + +// oauthTestServer поднимает фальшивого провайдера: обмен кода на токен и +// профиль. Адреса подставляются в каталог провайдеров на время теста. +func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.Form.Get("code") != "good-code" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) + return + } + // Провайдер отвечает JSON: так же ведёт себя GitHub при + // `Accept: application/json`, который ставит golang.org/x/oauth2. + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test-access-token", + "token_type": "bearer", + "expires_in": 3600, + }) + }) + mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-access-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + original := oauthProviderSet + oauthProviderSet = map[string]oauthProvider{ + "github": { + ID: "github", + Name: "GitHub", + Endpoint: oauth2.Endpoint{ + AuthURL: server.URL + "/authorize", + TokenURL: server.URL + "/token", + }, + Scopes: []string{"user:email"}, + FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token) (oauthProfile, error) { + // Провайдер получает токен так же, как в бою: через oauth2-клиент. + authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil) + if err != nil { + return oauthProfile{}, err + } + response, err := authorized.Do(request) + if err != nil { + return oauthProfile{}, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return oauthProfile{}, errors.New("profile request failed") + } + return profile, nil + }, + }, + } + t.Cleanup(func() { oauthProviderSet = original }) + return server +} + +func oauthTestService(t *testing.T, cfg config.Config) (*Service, *store.Store) { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, database.Options{ + Path: filepath.Join(t.TempDir(), "glchat.db"), + ReadPool: 2, + Migrate: true, + }) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + st := store.New(db) + service, err := New(ctx, cfg, st, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("auth.New: %v", err) + } + return service, st +} + +// TestOAuthNotConfigured — без клиентских id ручки отвечают понятной ошибкой, +// а не пытаются увести пользователя к провайдеру (AGENT.md 7.1). +func TestOAuthNotConfigured(t *testing.T) { + service, _ := oauthTestService(t, oauthTestBaseConfig()) + if service.OAuthEnabled() { + t.Fatal("oauth must be disabled without client credentials") + } + if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) { + t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err) + } + if _, err := service.OAuthAuthorizeURL("vk", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) { + t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err) + } + if len(service.OAuthProviders()) != 0 { + t.Fatalf("providers = %v, want empty", service.OAuthProviders()) + } +} + +// TestOAuthAuthorizeURL — ссылка ведёт к провайдеру и несёт подписанный state. +func TestOAuthAuthorizeURL(t *testing.T) { + oauthTestServer(t, oauthProfile{Subject: "1", Email: "user@example.com", EmailVerified: true}) + service, _ := oauthTestService(t, oauthTestConfig()) + + providers := service.OAuthProviders() + if len(providers) != 1 || providers[0].ID != "github" { + t.Fatalf("providers = %v, want [github]", providers) + } + authorizeURL, err := service.OAuthAuthorizeURL("github", "/app/invite/abc") + if err != nil { + t.Fatalf("authorize url: %v", err) + } + if !strings.HasPrefix(authorizeURL, "http") || !strings.Contains(authorizeURL, "client_id=client-id") { + t.Fatalf("unexpected authorize url: %s", authorizeURL) + } + parsed, err := url.Parse(authorizeURL) + if err != nil { + t.Fatalf("parse authorize url: %v", err) + } + state := parsed.Query().Get("state") + if state == "" || !strings.Contains(state, ".") { + t.Fatalf("state is not signed: %q", state) + } + redirect, err := service.verifyOAuthState("github", state) + if err != nil { + t.Fatalf("verify state: %v", err) + } + if redirect != "/app/invite/abc" { + t.Fatalf("redirect = %q", redirect) + } + // Подмена state и чужой провайдер отклоняются. + if _, err := service.verifyOAuthState("google", state); !errors.Is(err, ErrOAuthState) { + t.Fatalf("state for another provider must fail, got %v", err) + } + if _, err := service.verifyOAuthState("github", state+"x"); !errors.Is(err, ErrOAuthState) { + t.Fatalf("tampered state must fail, got %v", err) + } +} + +// TestOAuthCallbackCreatesAndLinksAccounts — вход новым аккаунтом, привязка к +// существующему по email, повторный вход и запрет для забаненного инстанса. +func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) { + oauthTestServer(t, oauthProfile{Subject: "42", Email: "OAuth@Example.com", EmailVerified: true, Login: "Octo Cat"}) + service, st := oauthTestService(t, oauthTestConfig()) + ctx := context.Background() + + state, err := service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent") + if err != nil { + t.Fatalf("oauth callback: %v", err) + } + if redirect != "/app" || token == "" || session == nil { + t.Fatalf("unexpected callback result: redirect=%q token=%q", redirect, token) + } + if user.Username != "octo_cat" { + t.Fatalf("username = %q, want octo_cat", user.Username) + } + // Email зашифрован, наружу не отдаётся, но расшифровывается сервисом. + email, err := service.Email(ctx, user.ID) + if err != nil || email != "oauth@example.com" { + t.Fatalf("email = %q, err = %v", email, err) + } + accounts, err := st.ListOAuthAccounts(ctx, user.ID) + if err != nil || len(accounts) != 1 { + t.Fatalf("oauth accounts = %v, err = %v", accounts, err) + } + if accounts[0].SubjectIndex == "" || strings.Contains(accounts[0].SubjectIndex, "42") { + t.Fatalf("subject must be stored as blind index: %q", accounts[0].SubjectIndex) + } + + // Повторный вход тем же аккаунтом: новой записи не появляется. + state, err = service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent") + if err != nil { + t.Fatalf("second oauth callback: %v", err) + } + if again.ID != user.ID { + t.Fatalf("second login created another account: %d != %d", again.ID, user.ID) + } + + // Бан инстанса запрещает вход через провайдера. + if err := st.BanInstanceUser(ctx, user.ID, user.ID, "тест"); err != nil { + t.Fatalf("ban user: %v", err) + } + state, err = service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) { + t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err) + } +} + +// TestOAuthCallbackLinksExistingAccountByEmail — провайдер привязывается к +// уже существующему аккаунту с тем же подтверждённым email (blind index). +func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) { + oauthTestServer(t, oauthProfile{Subject: "777", Email: "existing@example.com", EmailVerified: true, Login: "existing"}) + service, st := oauthTestService(t, oauthTestConfig()) + ctx := context.Background() + + if _, _, _, err := service.Register(ctx, RegisterInput{ + Username: "existing", Email: "existing@example.com", Password: "correct-horse-battery", + }); err != nil { + t.Fatalf("register: %v", err) + } + existing, err := service.UserByEmail(ctx, "existing@example.com") + if err != nil { + t.Fatalf("find user: %v", err) + } + + state, err := service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent") + if err != nil { + t.Fatalf("oauth callback: %v", err) + } + if user.ID != existing.ID { + t.Fatalf("oauth login must reuse the account: %d != %d", user.ID, existing.ID) + } + accounts, err := st.ListOAuthAccounts(ctx, user.ID) + if err != nil || len(accounts) != 1 { + t.Fatalf("link was not created: %v, err = %v", accounts, err) + } + events, err := st.ListSecurityEvents(ctx, user.ID, 20) + if err != nil { + t.Fatalf("list security events: %v", err) + } + found := false + for _, event := range events { + if event.Type == "oauth_linked" { + found = true + } + } + if !found { + t.Fatal("security events have no oauth_linked") + } +} + +// TestOAuthCallbackRejectsUnverifiedEmail — неподтверждённый email не даёт +// войти: иначе чужой адрес в профиле провайдера уводил бы в чужой аккаунт. +func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) { + oauthTestServer(t, oauthProfile{Subject: "5", Email: "unverified@example.com", EmailVerified: false}) + service, _ := oauthTestService(t, oauthTestConfig()) + + state, err := service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) { + t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err) + } +} + +// TestOAuthCallbackStateAndCodeErrors — неверный state и код отклоняются. +func TestOAuthCallbackStateAndCodeErrors(t *testing.T) { + oauthTestServer(t, oauthProfile{Subject: "6", Email: "user@example.com", EmailVerified: true}) + service, _ := oauthTestService(t, oauthTestConfig()) + ctx := context.Background() + + if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) { + t.Fatalf("bad state: err = %v, want ErrOAuthState", err) + } + state, err := service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) { + t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err) + } + if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) { + t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err) + } +} + +// TestOAuthRegistrationDisabledBlocksNewAccounts — существующие аккаунты +// входят, новые не создаются при выключенной регистрации (7.1). +func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) { + oauthTestServer(t, oauthProfile{Subject: "8", Email: "new@example.com", EmailVerified: true, Login: "new"}) + service, st := oauthTestService(t, oauthTestConfig()) + ctx := context.Background() + if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil { + t.Fatalf("disable registration: %v", err) + } + state, err := service.signOAuthState("github", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) { + t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err) + } +} + +// TestOAuthStateExpires — state старше окна не принимается, даже если подпись +// верна: иначе ссылку можно было бы использовать неограниченно долго. +func TestOAuthStateExpires(t *testing.T) { + service, _ := oauthTestService(t, oauthTestConfig()) + payload, err := json.Marshal(map[string]string{ + "provider": "github", + "redirect": "/app", + "issued": strconv.FormatInt(time.Now().UTC().Add(-time.Hour).Unix(), 10), + "nonce": "deadbeefdeadbeef", + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + expired := encoded + "." + service.signStatePayload(encoded) + if _, err := service.verifyOAuthState("github", expired); !errors.Is(err, ErrOAuthState) { + t.Fatalf("expired state: err = %v, want ErrOAuthState", err) + } +} + +// TestPasskeyHandleStableAndOpaque — user handle не раскрывает id аккаунта и +// стабилен между вызовами. +func TestPasskeyHandleStableAndOpaque(t *testing.T) { + service, _ := oauthTestService(t, oauthTestBaseConfig()) + first := service.passkeyHandle(12345) + second := service.passkeyHandle(12345) + if len(first) != 16 || !bytes.Equal(first, second) { + t.Fatalf("handle must be 16 stable bytes: %v / %v", first, second) + } + if strings.Contains(string(first), "12345") { + t.Fatal("handle must not contain the account id") + } + if bytes.Equal(service.passkeyHandle(12346), first) { + t.Fatal("handles of different accounts must differ") + } +} + +// TestPasskeyCeremonyStoreIsOneTime — церемония выдаётся один раз и по TTL. +func TestPasskeyCeremonyStoreIsOneTime(t *testing.T) { + store := newCeremonyStore() + now := time.Now().UTC() + store.now = func() time.Time { return now } + store.put("token", ceremony{userID: 1, expiresAt: now.Add(time.Minute)}) + if _, ok := store.take("token"); !ok { + t.Fatal("ceremony must be available once") + } + if _, ok := store.take("token"); ok { + t.Fatal("ceremony must not be reusable") + } + store.put("expired", ceremony{userID: 1, expiresAt: now.Add(-time.Second)}) + if _, ok := store.take("expired"); ok { + t.Fatal("expired ceremony must not be usable") + } +} + +// TestSanitizeUsername — логин провайдера приводится к правилам glchat. +func TestSanitizeUsername(t *testing.T) { + cases := map[string]string{ + "Octo-Cat": "octo_cat", + " John Doe ": "john_doe", + "ю": "", + "a": "", + "user.name": "user.name", + "UPPER": "upper", + } + for input, expected := range cases { + if got := sanitizeUsername(input); got != expected { + t.Errorf("sanitizeUsername(%q) = %q, want %q", input, got, expected) + } + } +} + +// TestSafeRedirect — открытый редирект невозможен. +func TestSafeRedirect(t *testing.T) { + if safeRedirect("//evil.example") != "/app" || safeRedirect("https://evil.example") != "/app" { + t.Fatal("external redirects must be rejected") + } + if safeRedirect("/app/invite/x") != "/app/invite/x" || safeRedirect("") != "/app" { + t.Fatal("internal redirects must be preserved") + } +} + +// проверяем, что ключ blind index провайдера не совпадает с email-индексом. +func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) { + key, err := crypto.ParseMasterKey(oauthTestConfig().MasterKey) + if err != nil { + t.Fatalf("parse master key: %v", err) + } + subject := key.BlindIndex("github:42") + email := key.BlindIndex("user@example.com") + if subject == email || len(subject) != len(email) { + t.Fatal("blind indexes must be independent") + } +} diff --git a/internal/auth/service.go b/internal/auth/service.go index ceeb824..1f829f9 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -60,6 +60,11 @@ type Service struct { webauthn *webauthn.WebAuthn // ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые). ceremonies *ceremonyStore + // oauth — включённые провайдеры (client id/secret из env), oauthRedirectBase — + // внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state. + oauth map[string]oauthClientCredentials + oauthRedirectBase string + stateKey string } type instanceSettings interface { @@ -108,6 +113,10 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L sessionTTL = store.SessionTTL } + // OAuth-провайдеры и RP-конфигурация passkeys: включаются конфигом, при + // отсутствии настроек соответствующие ручки отвечают понятной ошибкой. + oauthClients, oauthRedirectBase := oauthFromConfig(cfg, cfg.BaseURL()) + service := &Service{ store: st, hasher: hasher, @@ -117,7 +126,11 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L policy: policy, cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute}, logger: logger, - ceremonies: newCeremonyStore(), + // state OAuth подписывается секретом сессий: отдельного секрета не нужно. + oauth: oauthClients, + oauthRedirectBase: oauthRedirectBase, + stateKey: cfg.SessionPepper, + ceremonies: newCeremonyStore(), } // Passkeys требуют корректного RP ID: для IP-адреса (стенд без домена) // браузеры WebAuthn не работают, поэтому такой инстанс просто живёт без них. diff --git a/internal/config/config.go b/internal/config/config.go index 92483ff..1c33639 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -112,6 +112,13 @@ func Load() (Config, error) { RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24), RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second, + OAuthGitHubClientID: env("OAUTH_GITHUB_CLIENT_ID", ""), + OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""), + OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""), + OAuthGoogleClientSecret: env("OAUTH_GOOGLE_CLIENT_SECRET", ""), + OAuthDiscordClientID: env("OAUTH_DISCORD_CLIENT_ID", ""), + OAuthDiscordClientSecret: env("OAUTH_DISCORD_CLIENT_SECRET", ""), + OAuthRedirectBase: env("OAUTH_REDIRECT_BASE", ""), } if cfg.FilesDomain == "" { cfg.FilesDomain = "files." + cfg.Domain @@ -158,6 +165,9 @@ func (c Config) VoiceEnabled() bool { return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != "" } +// OAuthEnabled сообщает, настроен ли хотя бы один OAuth-провайдер (AGENT.md 7.1). +func (c Config) OAuthEnabled() bool { return len(c.OAuthProviders()) > 0 } + // WebAuthnEnabled сообщает, годится ли домен инстанса как RP ID для passkeys. // WebAuthn не работает на IP-адресах (исключение — localhost), поэтому стенд // без домена честно отдаёт `passkeys_enabled: false`, а не ломает вход. diff --git a/internal/database/migrations/00019_phase7_oauth.sql b/internal/database/migrations/00019_phase7_oauth.sql new file mode 100644 index 0000000..1a46bcc --- /dev/null +++ b/internal/database/migrations/00019_phase7_oauth.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Фаза 7: OAuth-провайдеры (AGENT.md 7.1). +-- +-- Связь внешнего аккаунта с пользователем инстанса. Внешний идентификатор +-- (subject) хранится только как blind index (HMAC-SHA-256 от +-- "provider:subject"), email — тоже индексом: сырых PII в таблице нет +-- (AGENT.md 9.2). Токены провайдеров не сохраняются вовсе: они нужны только +-- на время обмена кода на профиль. + +CREATE TABLE oauth_accounts ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider TEXT NOT NULL, + subject_index TEXT NOT NULL, + email_index TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_login_at TEXT, + UNIQUE (provider, subject_index) +); +CREATE INDEX oauth_accounts_user_idx ON oauth_accounts (user_id); + +-- +goose Down +DROP TABLE oauth_accounts; diff --git a/internal/meta/meta.go b/internal/meta/meta.go index b167eea..8f0bbd7 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -13,6 +13,9 @@ type Features struct { WebPushEnabled bool `json:"web_push_enabled"` OAuthEnabled bool `json:"oauth_enabled"` PasskeysEnabled bool `json:"passkeys_enabled"` + // OAuthProviders — включённые провайдеры входа (id): клиент рисует по + // списку кнопки, выключенные не показывает (Фаза 7). + OAuthProviders []string `json:"oauth_providers"` } type Response struct { @@ -47,8 +50,9 @@ func New(cfg config.Config) Response { VoiceEnabled: cfg.VoiceEnabled(), VoiceURL: cfg.LiveKitURL, WebPushEnabled: false, - OAuthEnabled: false, + OAuthEnabled: cfg.OAuthEnabled(), PasskeysEnabled: cfg.WebAuthnEnabled(), + OAuthProviders: cfg.OAuthProviders(), }, } } diff --git a/internal/server/errors.go b/internal/server/errors.go index 43f73d0..fb4ea43 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -81,6 +81,25 @@ func newAPIError(err error) apiError { case errors.Is(err, auth.ErrPasskeyLimit): candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit" candidate.Message = "passkey limit reached for this account" + // OAuth-провайдеры (Фаза 7). + case errors.Is(err, auth.ErrOAuthNotConfigured): + candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_not_configured" + candidate.Message = "oauth provider is not configured on this instance" + case errors.Is(err, auth.ErrOAuthUnknownProvider): + candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_unknown" + candidate.Message = "unknown oauth provider" + case errors.Is(err, auth.ErrOAuthState): + candidate.Status, candidate.Code = http.StatusBadRequest, "oauth.state_invalid" + candidate.Message = "oauth state is invalid or expired" + case errors.Is(err, auth.ErrOAuthEmailUnverified): + candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_unverified" + candidate.Message = "provider did not confirm the email address" + case errors.Is(err, auth.ErrOAuthEmailMissing): + candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_missing" + candidate.Message = "provider did not return an email address" + case errors.Is(err, auth.ErrOAuthExchange): + candidate.Status, candidate.Code = http.StatusBadGateway, "oauth.exchange_failed" + candidate.Message = "oauth provider rejected the request" case errors.Is(err, store.ErrNotFound): candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found" case errors.Is(err, store.ErrConflict): diff --git a/internal/server/oauth.go b/internal/server/oauth.go new file mode 100644 index 0000000..1645c0f --- /dev/null +++ b/internal/server/oauth.go @@ -0,0 +1,110 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "glchat/internal/auth" + "glchat/internal/httpx" +) + +// registerOAuthRoutes вешает ручки входа через внешние провайдеры (AGENT.md 7.1). +// `start` и `callback` — публичные (это вход), лимит по IP общий с другими +// ручками аутентификации. +func (s *Server) registerOAuthRoutes(router chi.Router) { + oauthLimit := s.oauthLimiter.Middleware(func(r *http.Request) string { + return httpx.ClientIP(r, nil) + }) + router.Get("/auth/oauth/providers", s.handleOAuthProviders) + router.With(oauthLimit).Get("/auth/oauth/{provider}/start", s.handleOAuthStart) + router.With(oauthLimit).Get("/auth/oauth/{provider}/callback", s.handleOAuthCallback) +} + +// handleOAuthProviders отдаёт список включённых провайдеров: клиент рисует по +// нему кнопки входа, а закрытые провайдеры не показывает вовсе. +func (s *Server) handleOAuthProviders(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + writeJSON(w, map[string]any{"providers": []any{}, "redirect_url": ""}) + return + } + writeJSON(w, map[string]any{ + "providers": s.auth.OAuthProviders(), + "redirect_url": s.auth.OAuthRedirectURL(), + }) +} + +// handleOAuthStart уводит браузер на страницу согласия провайдера. +// Если провайдер не настроен — отвечаем понятной ошибкой, а не редиректом. +func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + writeAPIError(w, auth.ErrSessionExpired) + return + } + provider := chi.URLParam(r, "provider") + redirect := r.URL.Query().Get("redirect") + authorizeURL, err := s.auth.OAuthAuthorizeURL(provider, redirect) + if err != nil { + writeAPIError(w, err) + return + } + //nolint:gosec // адрес берётся из каталога провайдеров, пользователь его не задаёт + http.Redirect(w, r, authorizeURL, http.StatusFound) +} + +// handleOAuthCallback принимает код, находит или создаёт аккаунт и возвращает +// пользователя в клиент. Ошибки уходят в `?oauth_error=<код>`: браузер видит +// понятное сообщение на странице входа, а не JSON. +func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + writeAPIError(w, auth.ErrSessionExpired) + return + } + provider := chi.URLParam(r, "provider") + query := r.URL.Query() + _, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider, + query.Get("code"), query.Get("state"), + httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context())) + if err != nil { + code := oauthErrorCode(err) + s.logger.InfoContext(r.Context(), "вход через OAuth не удался", + slog.String("provider", provider), slog.String("code", code)) + http.Redirect(w, r, "/login?oauth_error="+url.QueryEscape(code), http.StatusFound) + return + } + http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt)) + // Признак в адресе нужен клиенту, чтобы перечитать профиль после возврата. + target := redirect + if target == "" || target == "/" { + target = "/app" + } + separator := "?" + if strings.Contains(target, "?") { + separator = "&" + } + //nolint:gosec // target — только внутренний путь: его проверил safeRedirect + http.Redirect(w, r, target+separator+"oauth=ok", http.StatusFound) +} + +// oauthErrorCode переводит доменную ошибку в код для `?oauth_error=`. +func oauthErrorCode(err error) string { + for _, candidate := range []error{ + auth.ErrOAuthNotConfigured, + auth.ErrOAuthUnknownProvider, + auth.ErrOAuthState, + auth.ErrOAuthEmailUnverified, + auth.ErrOAuthEmailMissing, + auth.ErrRegistrationOff, + auth.ErrUserBanned, + auth.ErrOAuthExchange, + } { + if errors.Is(err, candidate) { + return strings.TrimPrefix(candidate.Error(), "oauth.") + } + } + return "failed" +} diff --git a/internal/server/oauth_test.go b/internal/server/oauth_test.go new file mode 100644 index 0000000..e2aee68 --- /dev/null +++ b/internal/server/oauth_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает +// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7). +func TestOAuthProviderNotConfigured(t *testing.T) { + srv, _ := newTestServer(t) + + rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "") + if rec.Code != http.StatusNotFound { + t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } + if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" { + t.Fatalf("error code = %q, want oauth.provider_not_configured", code) + } + + rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/vk/start", "") + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown provider = %d, want 404", rec.Code) + } + if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" { + t.Fatalf("error code = %q, want oauth.provider_unknown", code) + } + + // Список провайдеров пуст, клиент не рисует кнопки. + rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "") + if rec.Code != http.StatusOK { + t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String()) + } + var payload struct { + Providers []struct { + ID string `json:"id"` + } `json:"providers"` + RedirectURL string `json:"redirect_url"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode providers: %v", err) + } + if len(payload.Providers) != 0 { + t.Fatalf("providers = %s, want empty", rec.Body.String()) + } + + // Мета инстанса честно сообщает, что провайдеры выключены. + meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "") + if meta.Code != http.StatusOK { + t.Fatalf("meta = %d", meta.Code) + } + if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) { + t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String()) + } + if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) { + t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String()) + } +} + +// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state +// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию. +func TestOAuthCallbackRejectsUnsignedState(t *testing.T) { + srv, _ := newTestServer(t) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, request) + if rec.Code != http.StatusFound { + t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String()) + } + location := rec.Header().Get("Location") + if !strings.HasPrefix(location, "/login?oauth_error=") { + t.Fatalf("callback must redirect to the login page with a code, got %q", location) + } + if len(rec.Result().Cookies()) != 0 { + t.Fatal("failed oauth callback must not set a session cookie") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index e1373a3..d499629 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,9 +67,10 @@ type Server struct { uploadDailyLimiter *httpx.RateLimiter // reactionLimiter — 20 реакций в минуту на пользователя. reactionLimiter *httpx.RateLimiter - // passkeyLimiter ограничивает новые ручки входа (Фаза 7): 10 запросов в - // минуту на IP — с запасом на пару begin/finish. + // passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7): + // 10 запросов в минуту на IP — с запасом на пару begin/finish. passkeyLimiter *httpx.RateLimiter + oauthLimiter *httpx.RateLimiter // slowmode — время последней отправки в комнату для режима медленной // отправки; словарь ограничен по размеру (AGENT.md 7.5). slowmodeMu sync.Mutex @@ -128,6 +129,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100), reactionLimiter: httpx.NewRateLimiter(20, 20), passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10), + oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10), slowmode: map[string]time.Time{}, presence: map[uint64]time.Time{}, webhookSeen: map[string]time.Time{}, @@ -159,6 +161,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerAuthRoutes(apiRouter) if deps.Auth != nil { s.registerPasskeyRoutes(apiRouter) + s.registerOAuthRoutes(apiRouter) } if deps.Store != nil { s.registerUserRoutes(s.api) diff --git a/internal/store/oauth.go b/internal/store/oauth.go new file mode 100644 index 0000000..f48fbe7 --- /dev/null +++ b/internal/store/oauth.go @@ -0,0 +1,95 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// OAuthAccount — связь внешнего провайдера с пользователем инстанса. +// Сырого идентификатора и email здесь нет: только blind index (AGENT.md 9.2). +type OAuthAccount struct { + ID uint64 + UserID uint64 + Provider string + SubjectIndex string + EmailIndex string + CreatedAt time.Time + LastLoginAt *time.Time +} + +// LinkOAuthAccount привязывает внешний аккаунт к пользователю. Повторная +// привязка того же провайдера к другому пользователю отклоняется уникальным +// индексом (provider, subject_index) — это защита от подмены владельца. +func (s *Store) LinkOAuthAccount(ctx context.Context, userID uint64, provider, subjectIndex, emailIndex string) (*OAuthAccount, error) { + id := s.NextID() + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO oauth_accounts (id, user_id, provider, subject_index, email_index, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + int64(id), int64(userID), provider, subjectIndex, emailIndex, s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetOAuthAccount(ctx, id) +} + +func (s *Store) GetOAuthAccount(ctx context.Context, id uint64) (*OAuthAccount, error) { + row := s.reader.QueryRowContext(ctx, ` + SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at + FROM oauth_accounts WHERE id = ?`, int64(id)) + return scanOAuthAccount(row) +} + +// OAuthAccountBySubject ищет привязку по blind index внешнего идентификатора. +func (s *Store) OAuthAccountBySubject(ctx context.Context, provider, subjectIndex string) (*OAuthAccount, error) { + row := s.reader.QueryRowContext(ctx, ` + SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at + FROM oauth_accounts WHERE provider = ? AND subject_index = ?`, provider, subjectIndex) + return scanOAuthAccount(row) +} + +// ListOAuthAccounts возвращает привязки пользователя: показываются в настройках +// безопасности, чтобы входы через провайдеров не были невидимыми. +func (s *Store) ListOAuthAccounts(ctx context.Context, userID uint64) ([]OAuthAccount, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at + FROM oauth_accounts WHERE user_id = ? ORDER BY created_at`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + accounts := make([]OAuthAccount, 0, 2) + for rows.Next() { + account, err := scanOAuthAccount(rows) + if err != nil { + return nil, err + } + accounts = append(accounts, *account) + } + return accounts, rows.Err() +} + +// TouchOAuthAccount отмечает вход через провайдера. +func (s *Store) TouchOAuthAccount(ctx context.Context, id uint64) error { + _, err := s.writer.ExecContext(ctx, `UPDATE oauth_accounts SET last_login_at = ? WHERE id = ?`, s.Now(), int64(id)) + return err +} + +func scanOAuthAccount(scanner interface{ Scan(...any) error }) (*OAuthAccount, error) { + var ( + account OAuthAccount + createdAt string + lastLoginAt sql.NullString + ) + if err := scanner.Scan(&account.ID, &account.UserID, &account.Provider, &account.SubjectIndex, + &account.EmailIndex, &createdAt, &lastLoginAt); err != nil { + return nil, mapError(err) + } + account.CreatedAt = parseTimestamp(createdAt) + if lastLoginAt.Valid { + value := parseTimestamp(lastLoginAt.String) + account.LastLoginAt = &value + } + return &account, nil +} diff --git a/web/src/api/oauth.ts b/web/src/api/oauth.ts new file mode 100644 index 0000000..e6ace44 --- /dev/null +++ b/web/src/api/oauth.ts @@ -0,0 +1,41 @@ +import { request } from './client'; + +/** Провайдер входа, включённый на инстансе (AGENT.md 7.1, Фаза 7). */ +export interface OAuthProvider { + id: string; + name: string; +} + +export interface OAuthProvidersResponse { + providers: OAuthProvider[]; + /** Адрес возврата: его оператор прописывает в настройках приложения. */ + redirect_url: string; +} + +export function fetchOAuthProviders(signal?: AbortSignal): Promise { + return request( + '/auth/oauth/providers', + signal === undefined ? {} : { signal }, + ); +} + +/** + * Ссылка входа через провайдера. Браузер уходит по ней целиком (обычная + * навигация): cookie сессии выставляет сервер после возврата. + */ +export function oauthStartURL(provider: string, redirect?: string): string { + const query = new URLSearchParams(); + if (redirect !== undefined && redirect !== '') { + query.set('redirect', redirect); + } + const suffix = query.toString(); + return `/api/v1/auth/oauth/${encodeURIComponent(provider)}/start${suffix === '' ? '' : `?${suffix}`}`; +} + +/** Код ошибки возврата (`?oauth_error=`), если вход через провайдера не удался. */ +export function oauthErrorCode(value: string | null): string | null { + if (value === null || value === '') { + return null; + } + return value; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4ab8bc1..61d3cdc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -81,6 +81,12 @@ "auth.passkey_unknown": "This key is not registered on the account.", "auth.passkey_name_invalid": "Enter a key name (up to 64 characters).", "auth.passkey_limit": "The account reached its passkey limit.", + "oauth.provider_not_configured": "The provider is not configured on this instance.", + "oauth.provider_unknown": "Unknown sign-in provider.", + "oauth.state_invalid": "The sign-in link expired. Start again.", + "oauth.email_unverified": "The provider did not confirm the email — sign-in is impossible.", + "oauth.email_missing": "The provider returned no email — sign-in is impossible.", + "oauth.exchange_failed": "The provider rejected the request. Try later.", "permissions.denied": "You do not have permission for this action.", "instance.admin_required": "This action is available to the instance admin only.", "guild.not_found": "Server not found.", @@ -176,7 +182,14 @@ "passkeySubmit": "Sign in with a passkey", "passkeyPending": "Waiting for the key…", "passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.", - "passkeyUnsupported": "This browser does not support passkeys. Sign in with your password." + "passkeyUnsupported": "This browser does not support passkeys. Sign in with your password.", + "oauthHint": "Or sign in with an external service:", + "oauthSubmit": "Sign in with {{provider}}", + "oauthDone": "Signed in. Finishing…", + "oauthFailed": "External sign-in failed. Try again or use your password.", + "oauthNotConfigured": "This sign-in method is disabled on the instance. Use your password.", + "oauthEmailUnverified": "The provider did not confirm your email — sign-in is impossible.", + "oauthEmailMissing": "The provider returned no email — use your password or contact the administrator." }, "register": { "title": "Create account", @@ -828,7 +841,11 @@ "passkeysTimeout": "The authenticator did not respond in time. Try again.", "passkeysAlreadyRegistered": "This key is already registered on the account.", "passkeysFailed": "Could not create the key. Try another authenticator.", - "passkeysNameRequired": "Enter a key name (up to 64 characters)." + "passkeysNameRequired": "Enter a key name (up to 64 characters).", + "oauthTitle": "External sign-in providers", + "oauthDescription": "Providers are enabled by the instance administrator. An account is linked automatically on sign-in when the provider confirms the email.", + "oauthDisabled": "No external providers are configured on this instance.", + "oauthLinkedByEmail": "sign-in via confirmed email" }, "appearance": { "title": "Appearance", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index d5bda57..5eadd4a 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -81,6 +81,12 @@ "auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.", "auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).", "auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.", + "oauth.provider_not_configured": "Провайдер не настроен на инстансе.", + "oauth.provider_unknown": "Неизвестный провайдер входа.", + "oauth.state_invalid": "Ссылка входа устарела. Начните вход заново.", + "oauth.email_unverified": "Провайдер не подтвердил почту — вход невозможен.", + "oauth.email_missing": "Провайдер не отдал почту — вход невозможен.", + "oauth.exchange_failed": "Провайдер отклонил запрос. Попробуйте позже.", "permissions.denied": "Недостаточно прав для этого действия.", "instance.admin_required": "Действие доступно только администратору инстанса.", "guild.not_found": "Сервер не найден.", @@ -176,7 +182,14 @@ "passkeySubmit": "Войти по ключу доступа", "passkeyPending": "Ждём ключ…", "passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.", - "passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем." + "passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем.", + "oauthHint": "Или войдите через внешний сервис:", + "oauthSubmit": "Войти через {{provider}}", + "oauthDone": "Вход выполнен. Завершаем…", + "oauthFailed": "Вход через внешний сервис не удался. Попробуйте ещё раз или войдите с паролем.", + "oauthNotConfigured": "Этот способ входа выключен на инстансе. Войдите с паролем.", + "oauthEmailUnverified": "Внешний сервис не подтвердил вашу почту — вход невозможен.", + "oauthEmailMissing": "Внешний сервис не отдал почту — войдите с паролем или обратитесь к администратору." }, "register": { "title": "Регистрация", @@ -828,7 +841,11 @@ "passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.", "passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.", "passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.", - "passkeysNameRequired": "Укажите название ключа (до 64 символов)." + "passkeysNameRequired": "Укажите название ключа (до 64 символов).", + "oauthTitle": "Вход через внешние сервисы", + "oauthDescription": "Провайдеры включаются администратором инстанса. Аккаунт привязывается автоматически при входе, если почта подтверждена провайдером.", + "oauthDisabled": "На этом инстансе внешние провайдеры не настроены.", + "oauthLinkedByEmail": "вход по подтверждённой почте" }, "appearance": { "title": "Внешний вид", diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 93be541..01488eb 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; import { login } from '@/api/auth'; import { fetchMeta, metaQueryKey } from '@/api/meta'; +import { fetchOAuthProviders, oauthStartURL } from '@/api/oauth'; import { loginWithPasskey } from '@/api/passkeys'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; @@ -24,7 +25,8 @@ interface LocationState { * приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке * входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код. * - * Дополнительно (Фаза 7): вход по ключу доступа (passkeys). + * Дополнительно (Фаза 7): вход по ключу доступа (passkeys) и кнопки внешних + * провайдеров, включённых на инстансе. */ export default function LoginPage() { const { t } = useTranslation(); @@ -44,6 +46,13 @@ export default function LoginPage() { const stateFrom = (location.state as LocationState | null)?.from ?? null; const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app'); + const oauthReturned = searchParams.get('oauth'); + const oauthError = searchParams.get('oauth_error'); + const providers = useQuery({ + queryKey: ['auth', 'oauth', 'providers'], + queryFn: ({ signal }) => fetchOAuthProviders(signal), + staleTime: 5 * 60 * 1000, + }); // Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд // без домена) WebAuthn невозможен, и кнопку показывать нельзя. const meta = useQuery({ @@ -88,6 +97,23 @@ export default function LoginPage() { }; const registrationEnabled = instance.data?.registration_enabled === true; + const providerList = providers.data?.providers ?? []; + + // Ошибка возврата от провайдера: показываем человеческий текст. + const oauthErrorMessage = + oauthError === null + ? null + : t( + oauthError === 'provider_not_configured' + ? 'auth.login.oauthNotConfigured' + : oauthError === 'email_unverified' + ? 'auth.login.oauthEmailUnverified' + : oauthError === 'email_missing' + ? 'auth.login.oauthEmailMissing' + : oauthError === 'user.banned' + ? 'errors.user.banned' + : 'auth.login.oauthFailed', + ); // Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать. const passkeyErrorKey = @@ -105,6 +131,17 @@ export default function LoginPage() { + {oauthReturned === 'ok' ? ( +

+ {t('auth.login.oauthDone')} +

+ ) : null} + {oauthErrorMessage === null ? null : ( +

+ {oauthErrorMessage} +

+ )} +
) : null} + {providerList.length === 0 ? null : ( +
+

{t('auth.login.oauthHint')}

+ {providerList.map((provider) => ( + + ))} +
+ )} +

{t('auth.login.noAccount')}{' '} {registrationEnabled ? ( diff --git a/web/src/pages/settings/LinkedAccountsSection.tsx b/web/src/pages/settings/LinkedAccountsSection.tsx new file mode 100644 index 0000000..cf80ee0 --- /dev/null +++ b/web/src/pages/settings/LinkedAccountsSection.tsx @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { fetchOAuthProviders } from '@/api/oauth'; +import { Card } from '@/components/ui/primitives'; + +/** + * Вход через внешние провайдеры (Фаза 7). Раздел только показывает, какие + * провайдеры включены на инстансе: привязка происходит при входе по + * подтверждённому email, а отвязка — действие оператора/администратора. + */ +export function LinkedAccountsSection() { + const { t } = useTranslation(); + const providers = useQuery({ + queryKey: ['auth', 'oauth', 'providers'], + queryFn: ({ signal }) => fetchOAuthProviders(signal), + staleTime: 5 * 60 * 1000, + }); + const enabled = providers.data?.providers ?? []; + + return ( + +

{t('settings.security.oauthTitle')}

+

{t('settings.security.oauthDescription')}

+ + {enabled.length === 0 ? ( +

+ {t('settings.security.oauthDisabled')} +

+ ) : ( +
    + {enabled.map((provider) => ( +
  • + {provider.name} + + {t('settings.security.oauthLinkedByEmail')} + +
  • + ))} +
+ )} + + ); +} diff --git a/web/src/pages/settings/SecuritySettingsPage.tsx b/web/src/pages/settings/SecuritySettingsPage.tsx index 8d37296..9f6e9b0 100644 --- a/web/src/pages/settings/SecuritySettingsPage.tsx +++ b/web/src/pages/settings/SecuritySettingsPage.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { currentUserQueryKey, fetchCurrentUser } from '@/api/users'; +import { LinkedAccountsSection } from '@/pages/settings/LinkedAccountsSection'; import { PasskeysSection } from '@/pages/settings/PasskeysSection'; import { PasswordSection } from '@/pages/settings/PasswordSection'; import { SessionsSection } from '@/pages/settings/SessionsSection'; @@ -9,7 +10,8 @@ import { TwoFactorSection } from '@/pages/settings/TwoFactorSection'; /** * Безопасность: смена пароля со step-up, список сессий с выходом на всех - * устройствах, включение 2FA и ключи доступа (passkeys, Фаза 7). + * устройствах, включение 2FA, ключи доступа (passkeys) и вход через внешние + * провайдеры (Фаза 7). * * Состояние 2FA берём из профиля (`totp_enabled`), обновляя запрос после * включения: от него зависит, требовать ли код при step-up. @@ -37,6 +39,7 @@ export default function SecuritySettingsPage() { }} /> + ); } diff --git a/web/tests/passkeys.test.tsx b/web/tests/passkeys.test.tsx index d13d6fe..2cb4c44 100644 --- a/web/tests/passkeys.test.tsx +++ b/web/tests/passkeys.test.tsx @@ -309,12 +309,13 @@ const metaWithPasskeys = { anti_bot_enabled: false, voice_enabled: false, web_push_enabled: false, - oauth_enabled: false, + oauth_enabled: true, passkeys_enabled: true, + oauth_providers: ['github'], }, }; -describe('страница входа: вход по ключу', () => { +describe('страница входа: ключи и внешние провайдеры', () => { it('входит по ключу и переводит в приложение', async () => { const assertion = { id: 'credential-id', @@ -357,6 +358,11 @@ describe('страница входа: вход по ключу', () => { method: 'POST', response: () => json({ user }), }, + { + match: '/api/v1/auth/oauth/providers', + response: () => + json({ providers: [{ id: 'github', name: 'GitHub' }], redirect_url: 'https://x' }), + }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, ]); @@ -364,6 +370,8 @@ describe('страница входа: вход по ключу', () => { const { router } = renderApp('/login'); const visitor = userEvent.setup(); + // Кнопка внешнего провайдера нарисована по списку с сервера. + expect(await screen.findByTestId('oauth-github')).toBeVisible(); await visitor.click(await screen.findByTestId('passkey-login')); await waitFor(() => { @@ -373,11 +381,39 @@ describe('страница входа: вход по ключу', () => { expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login'); }); + it('ошибка возврата от провайдера показывается на странице входа', async () => { + installFetch([ + { + match: '/api/v1/meta', + response: () => + json({ + ...metaWithPasskeys, + features: { ...metaWithPasskeys.features, passkeys_enabled: false }, + }), + }, + { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + { + match: '/api/v1/auth/oauth/providers', + response: () => json({ providers: [], redirect_url: 'https://x' }), + }, + ]); + + renderApp('/login?oauth_error=provider_not_configured'); + + expect(await screen.findByTestId('oauth-error')).toHaveTextContent( + 'Этот способ входа выключен на инстансе', + ); + }); + it('без поддержки браузера кнопка входа по ключу не показывается', async () => { vi.stubGlobal('PublicKeyCredential', undefined); installFetch([ { match: '/api/v1/meta', response: () => json(metaWithPasskeys) }, { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + { + match: '/api/v1/auth/oauth/providers', + response: () => json({ providers: [], redirect_url: 'https://x' }), + }, ]); renderApp('/login');