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

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

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

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

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

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
This commit is contained in:
2026-09-26 15:13:24 +03:00
parent cd1d662572
commit cebce0ae3b
23 changed files with 1721 additions and 12 deletions
+14 -1
View File
@@ -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 не работают, поэтому такой инстанс просто живёт без них.