feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты — обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование, события безопасности и аудит. Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin берутся из конфига домена; если домен — IP-адрес (стенд без домена), passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в памяти процесса 5 минут и одноразовые: повторная отправка challenge отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу webauthn_credentials под полную запись credential в JSON. Новые ручки входа ограничены по IP (10/мин). Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK, раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу» на экране входа; понятные сообщения для браузеров без поддержки WebAuthn и при отмене диалога. Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная проверка подписи P-256), отказ при чужом challenge, одноразовость церемонии, обязательный step-up при управлении ключами, запрет входа забаненному, ограничение allowCredentials при входе с почтой, лимит и валидация имени; web — 12 vitest с моком navigator.credentials; Playwright — живой сценарий с виртуальным аутентификатором Chromium.
This commit is contained in:
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -61,6 +62,17 @@ type Config struct {
|
||||
RetainAuditDays int
|
||||
RetainOrphanHours int
|
||||
RetainInterval time.Duration
|
||||
// OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами
|
||||
// client id/secret, пустые значения означают «провайдер выключен».
|
||||
OAuthGitHubClientID string
|
||||
OAuthGitHubClientSecret string
|
||||
OAuthGoogleClientID string
|
||||
OAuthGoogleClientSecret string
|
||||
OAuthDiscordClientID string
|
||||
OAuthDiscordClientSecret string
|
||||
// OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто —
|
||||
// берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу.
|
||||
OAuthRedirectBase string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -99,6 +111,7 @@ func Load() (Config, error) {
|
||||
RetainAuditDays: envInt("RETENTION_AUDIT_DAYS", 180),
|
||||
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
|
||||
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second,
|
||||
|
||||
}
|
||||
if cfg.FilesDomain == "" {
|
||||
cfg.FilesDomain = "files." + cfg.Domain
|
||||
@@ -145,6 +158,42 @@ func (c Config) VoiceEnabled() bool {
|
||||
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != ""
|
||||
}
|
||||
|
||||
// WebAuthnEnabled сообщает, годится ли домен инстанса как RP ID для passkeys.
|
||||
// WebAuthn не работает на IP-адресах (исключение — localhost), поэтому стенд
|
||||
// без домена честно отдаёт `passkeys_enabled: false`, а не ломает вход.
|
||||
func (c Config) WebAuthnEnabled() bool {
|
||||
host := strings.TrimSpace(c.Domain)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = parsed
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
return net.ParseIP(host) == nil
|
||||
}
|
||||
|
||||
// OAuthProviders перечисляет включённые провайдеры в стабильном порядке.
|
||||
func (c Config) OAuthProviders() []string {
|
||||
providers := make([]string, 0, 3)
|
||||
for _, candidate := range []struct {
|
||||
id string
|
||||
client string
|
||||
secret string
|
||||
}{
|
||||
{"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret},
|
||||
{"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret},
|
||||
{"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret},
|
||||
} {
|
||||
if candidate.client != "" && candidate.secret != "" {
|
||||
providers = append(providers, candidate.id)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
|
||||
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user