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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 не работают, поэтому такой инстанс просто живёт без них.
|
||||
|
||||
@@ -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`, а не ломает вход.
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user