Files
glchat/internal/config/config.go
T
grendervill cebce0ae3b 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 — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
2026-09-26 15:13:24 +03:00

283 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
const (
DefaultListenAddr = "127.0.0.1:8080"
DefaultDataDir = "/opt/glchat/data"
DefaultLogLevel = "info"
)
type Config struct {
Domain string
FilesDomain string
InstanceName string
ListenAddr string
DataDir string
WebRoot string
// UpdatesDir — каталог с манифестами автообновления desktop-обёртки и
// артефактами релиза (docs/client-tauri.md §4). Наполняется релизным
// скриптом (scripts/desktop-release.sh), приложение только отдаёт файлы.
UpdatesDir string
DatabasePath string
SQLiteCache string
SQLiteMmap string
LogLevel string
LogFormat string
ReadPoolSize int
MaxUploadSize int64
TLSEnabled bool
// Секреты инстанса (генерируются установщиком, AGENT.md §10.3).
SessionPepper string
MasterKey string
TOTPEncryptionKey string
SessionTTLHours int
Argon2MemoryKiB int
Argon2Iterations int
Argon2Parallelism int
// LiveKit: голос, видео и шаринг экрана (AGENT.md 7.14).
LiveKitAPIKey string
LiveKitAPISecret string
LiveKitURL string
// LiveKitAPIURL — внутренний HTTP-адрес SFU для RoomService (модерация):
// в compose это http://livekit:7880, снаружи не публикуется.
LiveKitAPIURL string
// LiveKitTokenTTL — время жизни токена доступа к комнате (AGENT.md 7.14:
// по умолчанию 10 минут, клиент переиздаёт токен до истечения).
LiveKitTokenTTL time.Duration
Version string
Commit string
BuildDate string
ShutdownTTL time.Duration
Maintenance time.Duration
// Retention (AGENT.md 6.4, Фаза 5): сколько хранить сессии, записи аудита и
// файлы без ссылок. Период проверки — RetainInterval.
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) {
cfg := Config{
Domain: env("DOMAIN", "localhost"),
FilesDomain: env("FILES_DOMAIN", ""),
InstanceName: env("INSTANCE_NAME", "glchat"),
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
DataDir: env("DATA_DIR", DefaultDataDir),
WebRoot: env("WEB_ROOT", "/app/web"),
UpdatesDir: env("UPDATES_DIR", "/opt/glchat/updates"),
DatabasePath: env("DATABASE_PATH", ""),
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
TLSEnabled: envBool("TLS_ENABLED", true),
SessionPepper: env("SESSION_PEPPER", ""),
MasterKey: env("MASTER_KEY", ""),
TOTPEncryptionKey: env("TOTP_ENCRYPTION_KEY", ""),
SessionTTLHours: envInt("SESSION_TTL_HOURS", 720),
Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456),
Argon2Iterations: envInt("ARGON2_ITERATIONS", 2),
Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1),
LiveKitAPIKey: env("LIVEKIT_API_KEY", ""),
LiveKitAPISecret: env("LIVEKIT_API_SECRET", ""),
LiveKitURL: env("LIVEKIT_URL", ""),
LiveKitAPIURL: env("LIVEKIT_API_URL", "http://livekit:7880"),
Version: env("APP_VERSION", "dev"),
Commit: env("APP_COMMIT", "none"),
BuildDate: env("APP_BUILD_DATE", "unknown"),
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
RetainAuditDays: envInt("RETENTION_AUDIT_DAYS", 180),
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
}
if cfg.DatabasePath == "" {
cfg.DatabasePath = cfg.DataDir + "/db/glchat.db"
}
if cfg.ReadPoolSize < 1 {
return Config{}, fmt.Errorf("SQLITE_READ_POOL must be >= 1, got %d", cfg.ReadPoolSize)
}
if _, err := parseLogLevel(cfg.LogLevel); err != nil {
return Config{}, err
}
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
return Config{}, fmt.Errorf("LOG_FORMAT must be json or text, got %q", cfg.LogFormat)
}
return cfg, nil
}
// Scheme выбирается установщиком: при установке без TLS (--skip-tls,
// например стенд без домена) клиент должен получать http/ws, а не https/wss.
func (c Config) Scheme() string {
if c.TLSEnabled {
return "https"
}
return "http"
}
// AllowedOrigins возвращает домены, с которых разрешены WebSocket и
// state-changing запросы (AGENT.md 9.7: точный allowlist, никаких *).
func (c Config) AllowedOrigins() []string {
origins := []string{
"https://" + c.Domain,
"http://" + c.Domain,
}
if c.FilesDomain != "" && c.FilesDomain != c.Domain {
origins = append(origins, "https://"+c.FilesDomain, "http://"+c.FilesDomain)
}
return origins
}
// VoiceEnabled сообщает, настроен ли LiveKit: без ключей голос выключен.
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`, а не ломает вход.
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 }
func (c Config) GatewayURL() string {
scheme := "ws"
if c.TLSEnabled {
scheme = "wss"
}
return scheme + "://" + c.Domain + "/gateway"
}
func (c Config) LogLevelNumeric() int {
level, err := parseLogLevel(c.LogLevel)
if err != nil {
return 0
}
return level
}
func parseLogLevel(level string) (int, error) {
switch level {
case "debug":
return -4, nil
case "info":
return 0, nil
case "warn", "warning":
return 4, nil
case "error":
return 8, nil
default:
return 0, fmt.Errorf("unknown LOG_LEVEL %q", level)
}
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func envBool(key string, fallback bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
parsed, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return parsed
}
func envInt64(key string, fallback int64) int64 {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fallback
}
return n
}