Ctrl K
ComunidadGuías…

cmd/server/bootstrap.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"crypto/rand"
	"crypto/rsa"
	"encoding/base64"
	"errors"
	"fmt"
	"io/fs"
	"log/slog"
	"os"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/ory/fosite"
	goredis "github.com/redis/go-redis/v9"
	"golang.org/x/crypto/argon2"

	"github.com/anthropic/oidc-platform/internal/adapter/memcache"
	"github.com/anthropic/oidc-platform/internal/adapter/postgres"
	"github.com/anthropic/oidc-platform/internal/adapter/redis"
	"github.com/anthropic/oidc-platform/internal/adapter/smtp"
	"github.com/anthropic/oidc-platform/internal/adapter/social"
	sqliteAdapter "github.com/anthropic/oidc-platform/internal/adapter/sqlite"
	"github.com/anthropic/oidc-platform/internal/config"
	"github.com/anthropic/oidc-platform/internal/domain"
	"github.com/anthropic/oidc-platform/internal/handler"
	"github.com/anthropic/oidc-platform/internal/oidcprovider"
	"github.com/anthropic/oidc-platform/internal/port"
	"github.com/anthropic/oidc-platform/internal/router"
	"github.com/anthropic/oidc-platform/internal/service"
)

// bootstrap initializes all infrastructure and returns assembled router deps and a cleanup func.
func bootstrap(ctx context.Context, cfg *config.Config) (router.Deps, func(), error) {
	cleanup := func() {}

	var (
		userRepo         port.UserRepository
		bindingRepo      port.BindingRepository
		clientRepo       port.ClientRepository
		accessRuleRepo   port.ClientAccessRuleRepository
		ruleRepo         port.RuleRepository
		sessionRepo      port.SessionRepository
		providerCfgRepo  port.ProviderConfigRepository
		auditRepo        port.AuditRepository
		settingsRepo     port.SettingsRepository
		aliasRepo        port.AliasRestrictionRepository
		signingKeyRepo   port.SigningKeyRepository
		riskReportRepo   port.RiskReportRepository
		riskListRepo     port.RiskListRepository
		consentRepo      port.ConsentRepository
		passkeyRepo      port.PasskeyRepository
		announcementRepo port.AnnouncementRepository
		cache            port.Cache
		fositeStore      fosite.Storage
	)

	secretCipher, err := service.NewSecretCipher(cfg.Secrets.ClientSecretEncryptionKey)
	if err != nil {
		return router.Deps{}, cleanup, fmt.Errorf("client secret cipher: %w", err)
	}

	// Variables for health handler (nil when using sqlite).
	var pgPool *pgxpool.Pool
	var redisClient *goredis.Client

	switch cfg.Database.Driver {
	case "sqlite":
		sqliteDB, err := sqliteAdapter.NewDB(ctx, cfg.Database.DSN)
		if err != nil {
			return router.Deps{}, cleanup, fmt.Errorf("sqlite: %w", err)
		}
		cleanup = chainCleanup(cleanup, func() { sqliteDB.Close() })

		if err := sqliteAdapter.RunMigrations(sqliteDB); err != nil {
			return router.Deps{}, cleanup, fmt.Errorf("sqlite migrations: %w", err)
		}

		userRepo = sqliteAdapter.NewUserRepo(sqliteDB)
		bindingRepo = sqliteAdapter.NewBindingRepo(sqliteDB)
		clientRepo = sqliteAdapter.NewClientRepo(sqliteDB)
		accessRuleRepo = sqliteAdapter.NewClientAccessRuleRepo(sqliteDB)
		ruleRepo = sqliteAdapter.NewRuleRepo(sqliteDB)
		sessionRepo = sqliteAdapter.NewSessionRepo(sqliteDB)
		providerCfgRepo = sqliteAdapter.NewProviderConfigRepo(sqliteDB)
		auditRepo = sqliteAdapter.NewAuditRepo(sqliteDB)
		settingsRepo = sqliteAdapter.NewSettingsRepo(sqliteDB)
		aliasRepo = sqliteAdapter.NewAliasRestrictionRepo(sqliteDB)
		signingKeyRepo = sqliteAdapter.NewSigningKeyRepo(sqliteDB)
		riskReportRepo = sqliteAdapter.NewRiskReportRepo(sqliteDB)
		riskListRepo = sqliteAdapter.NewRiskListRepo(sqliteDB)
		consentRepo = sqliteAdapter.NewConsentRepo(sqliteDB)
		fositeStore = sqliteAdapter.NewFositeStore(sqliteDB, secretCipher)
		passkeyRepo = sqliteAdapter.NewPasskeyRepo(sqliteDB)
		announcementRepo = sqliteAdapter.NewAnnouncementRepo(sqliteDB)

		mc := memcache.NewMemCache()
		cleanup = chainCleanup(cleanup, func() { _ = mc.Close() })
		cache = mc

	default: // "postgres"
		db, err := postgres.NewDB(ctx, cfg.Database)
		if err != nil {
			return router.Deps{}, cleanup, fmt.Errorf("postgres: %w", err)
		}
		cleanup = chainCleanup(cleanup, func() { db.Close() })
		pgPool = db

		migrationsPath := os.Getenv("OIDC_MIGRATIONS_PATH")
		if migrationsPath == "" {
			migrationsPath = "db/migrations"
		}
		if err := postgres.RunMigrations(cfg.Database, migrationsPath); err != nil {
			return router.Deps{}, cleanup, fmt.Errorf("postgres migrations %q: %w", migrationsPath, err)
		}

		redisCache, err := redis.NewCache(ctx, cfg.Redis)
		if err != nil {
			return router.Deps{}, cleanup, fmt.Errorf("redis: %w", err)
		}
		cleanup = chainCleanup(cleanup, func() { _ = redisCache.Close() })
		redisClient = redisCache.Client()
		cache = redisCache

		userRepo = postgres.NewUserRepo(db)
		bindingRepo = postgres.NewBindingRepo(db)
		clientRepo = postgres.NewClientRepo(db)
		accessRuleRepo = postgres.NewClientAccessRuleRepo(db)
		ruleRepo = postgres.NewRuleRepo(db)
		sessionRepo = postgres.NewSessionRepo(db)
		providerCfgRepo = postgres.NewProviderConfigRepo(db)
		auditRepo = postgres.NewAuditRepo(db)
		settingsRepo = postgres.NewSettingsRepo(db)
		aliasRepo = postgres.NewAliasRestrictionRepo(db)
		signingKeyRepo = postgres.NewSigningKeyRepo(db)
		riskReportRepo = postgres.NewRiskReportRepo(db)
		riskListRepo = postgres.NewRiskListRepo(db)
		consentRepo = postgres.NewConsentRepo(db)
		fositeStore = postgres.NewFositeStore(db, secretCipher)
		passkeyRepo = postgres.NewPasskeyRepo(db)
		announcementRepo = postgres.NewAnnouncementRepo(db)
	}
	// settingsRepo is used by devHandler above.

	// Social provider registry.
	socialRegistry := buildSocialRegistry(cfg, providerCfgRepo)

	// Email sender.
	baseURL := cfg.Server.BaseURL
	if baseURL == "" {
		baseURL = fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
	}
	emailSender := smtp.NewSender(cfg.SMTP, baseURL, settingsRepo)

	// Services.
	cacheSvc := service.NewCacheService(cache)
	securitySvc := service.NewSecurityLevelService(ruleRepo, bindingRepo, userRepo, auditRepo)
	authSvc := service.NewAuthService(userRepo, sessionRepo, cache, auditRepo, emailSender, settingsRepo, cfg)
	sessionSvc := service.NewSessionService(sessionRepo, userRepo, cfg)
	socialSvc := service.NewSocialService(bindingRepo, userRepo, socialRegistry, cache, securitySvc, sessionRepo, auditRepo, settingsRepo, riskListRepo, cfg)
	clientSvc := service.NewClientService(clientRepo, accessRuleRepo, auditRepo, secretCipher)
	adminSvc := service.NewAdminService(userRepo, providerCfgRepo, settingsRepo, aliasRepo, signingKeyRepo, auditRepo, passkeyRepo, cfg.Security)
	accessCtrl := service.NewAccessControlService(accessRuleRepo, aliasRepo)
	riskSvc := service.NewRiskService(riskReportRepo, riskListRepo, bindingRepo, userRepo, auditRepo, securitySvc, emailSender)

	// Passkey service.
	passkeySvc, err := service.NewPasskeyService(passkeyRepo, userRepo, sessionRepo, cache, settingsRepo, cfg)
	if err != nil {
		return router.Deps{}, cleanup, fmt.Errorf("passkey service: %w", err)
	}

	// OIDC provider.
	privKey, err := loadOrCreateSigningKey(ctx, signingKeyRepo, adminSvc)
	if err != nil {
		return router.Deps{}, cleanup, fmt.Errorf("signing key: %w", err)
	}
	oauth2Secret := []byte(os.Getenv("OIDC_OAUTH2_SECRET"))
	if len(oauth2Secret) < 32 {
		slog.Warn("OIDC_OAUTH2_SECRET is not set or too short (min 32 chars), using random secret (sessions will not survive restart)")
		randomBytes := make([]byte, 32)
		if _, err := rand.Read(randomBytes); err != nil {
			return router.Deps{}, cleanup, fmt.Errorf("generate random secret: %w", err)
		}
		oauth2Secret = randomBytes
	}
	provider := oidcprovider.NewOAuth2Provider(fositeStore, oauth2Secret, privKey, cfg.Server.Issuer)

	// Handlers.
	authHandler := handler.NewAuthHandler(authSvc, sessionSvc, cfg.Session)
	socialHandler := handler.NewSocialHandler(socialSvc, socialRegistry, sessionSvc, cfg.Session)
	userInfoHandler := handler.NewUserInfoHandler(userRepo, socialSvc, securitySvc, accessCtrl, authSvc, sessionSvc, consentRepo, auditRepo, riskSvc, clientSvc)
	adminHandler := handler.NewAdminHandler(adminSvc, clientSvc, securitySvc, userRepo, socialRegistry, riskSvc, sessionRepo, bindingRepo, consentRepo, cacheSvc)
	oidcHandler := handler.NewOIDCHandler(provider, userRepo, clientSvc, accessCtrl, sessionSvc, settingsRepo, cfg.Server, "/login")
	oidcHandler.SetCache(cache)
	wellKnownHandler := handler.NewWellKnownHandler(cfg.Server.BaseURL, settingsRepo, signingKeyRepo)
	devHandler := handler.NewDeveloperHandler(clientSvc, riskSvc, userRepo, settingsRepo, consentRepo, cfg.Server.Issuer)
	healthHandler := handler.NewHealthHandler(pgPool, redisClient)
	passkeyHandler := handler.NewPasskeyHandler(passkeySvc, cfg.Session)
	announcementHandler := handler.NewAnnouncementHandler(service.NewAnnouncementService(announcementRepo, auditRepo))

	allowedOrigins := []string{"*"}
	if v := os.Getenv("OIDC_ALLOWED_ORIGINS"); v != "" {
		allowedOrigins = strings.Split(v, ",")
	}

	startBackgroundJobs(ctx, sessionRepo, securitySvc, socialSvc, cfg.SocialAuthSync)

	if cfg.Admin.Email != "" && cfg.Admin.Password != "" {
		if err := seedAdmin(ctx, userRepo, cfg.Admin); err != nil {
			slog.Warn("seed admin", "error", err)
		}
	}

	seedProviders(ctx, providerCfgRepo)
	seedSettings(ctx, settingsRepo, cfg)

	return router.Deps{
		AuthHandler:         authHandler,
		SocialHandler:       socialHandler,
		OIDCHandler:         oidcHandler,
		UserInfoHandler:     userInfoHandler,
		AdminHandler:        adminHandler,
		DeveloperHandler:    devHandler,
		WellKnownHandler:    wellKnownHandler,
		HealthHandler:       healthHandler,
		PasskeyHandler:      passkeyHandler,
		AnnouncementHandler: announcementHandler,
		SessionService:      sessionSvc,
		UserRepo:            userRepo,
		SettingsRepo:        settingsRepo,
		Cache:               cache,
		AllowedOrigins:      allowedOrigins,
		CookieName:          cfg.Session.CookieName,
		SPAFS:               locateSPAFS(),
	}, cleanup, nil
}

func locateSPAFS() fs.FS {
	for _, dir := range []string{"frontend/dist", "../frontend/dist", "dist"} {
		if info, err := os.Stat(dir); err == nil && info.IsDir() {
			slog.Info("serving SPA from", "dir", dir)
			return os.DirFS(dir)
		}
	}
	slog.Warn("no frontend dist found, SPA disabled")
	return nil
}

func chainCleanup(a, b func()) func() {
	return func() {
		b()
		a()
	}
}

func buildSocialRegistry(cfg *config.Config, providerCfgRepo port.ProviderConfigRepository) port.SocialProviderRegistry {
	reg := social.NewRegistry(providerCfgRepo)

	// Load from config.yaml first (static config).
	for _, name := range domain.AllProviders() {
		pcfg, ok := cfg.OAuth2.Providers[name]
		if !ok || !pcfg.Enabled {
			continue
		}
		var p port.SocialProvider
		switch name {
		case domain.ProviderGitHub:
			p = social.NewGitHubProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes)
		case domain.ProviderGoogle:
			p = social.NewGoogleProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes)
		case domain.ProviderGitLab:
			p = social.NewGitLabProvider(pcfg.ClientID, pcfg.ClientSecret, "", pcfg.Scopes)
		case domain.ProviderGitee:
			p = social.NewGiteeProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes)
		case domain.ProviderLinuxDO:
			p = social.NewLinuxDOProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes)
		case domain.ProviderDiscord:
			p = social.NewDiscordProviderWithGuilds(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes, pcfg.GuildIDs)
		case domain.ProviderMicrosoft:
			p = social.NewMicrosoftProvider(pcfg.ClientID, pcfg.ClientSecret, "", pcfg.Scopes)
		case domain.ProviderQQ:
			p = social.NewQQProvider(pcfg.ClientID, pcfg.ClientSecret, pcfg.Scopes)
		case domain.ProviderWeChat:
			p = social.NewWeChatProvider(pcfg.AppID, pcfg.AppSecret)
		case domain.ProviderTelegram:
			p = social.NewTelegramProvider(pcfg.AppSecret)
		default:
			slog.Warn("provider not wired", "provider", name)
			continue
		}
		if p != nil {
			reg.Register(p)
		}
	}

	// Then overlay with DB-stored configs (admin-configured providers).
	if err := reg.Reload(context.Background()); err != nil {
		slog.Warn("reload social providers from DB", "error", err)
	}

	return reg
}

func loadOrCreateSigningKey(ctx context.Context, repo port.SigningKeyRepository, adminSvc *service.AdminService) (*rsa.PrivateKey, error) {
	k, err := repo.GetCurrent(ctx)
	if err == nil && k != nil {
		key, err := parsePrivateKey(k.PrivateKey)
		if err != nil {
			return nil, err
		}
		rk, ok := key.(*rsa.PrivateKey)
		if !ok {
			return nil, fmt.Errorf("not an RSA private key")
		}
		return rk, nil
	}
	// Bootstrap: create the first signing key via admin service.
	newKey, err := adminSvc.RotateSigningKey(ctx)
	if err != nil {
		return nil, fmt.Errorf("create initial signing key: %w", err)
	}
	key, err := parsePrivateKey(newKey.PrivateKey)
	if err != nil {
		return nil, err
	}
	rk, ok := key.(*rsa.PrivateKey)
	if !ok {
		return nil, fmt.Errorf("not an RSA private key")
	}
	return rk, nil
}

func seedAdmin(ctx context.Context, userRepo port.UserRepository, cfg config.AdminConfig) error {
	existing, err := userRepo.GetByEmail(ctx, cfg.Email)
	if err != nil && !errors.Is(err, port.ErrNotFound) {
		return err
	}
	if existing != nil {
		return nil
	}

	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		return err
	}
	key := argon2.IDKey([]byte(cfg.Password), salt, 1, 64*1024, 4, 32)
	hash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, 64*1024, 1, 4,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key))

	now := time.Now().UTC()
	admin := &domain.User{
		ID:            uuid.New(),
		Email:         cfg.Email,
		EmailVerified: true,
		PasswordHash:  hash,
		DisplayName:   "Admin",
		Role:          domain.RoleSuperAdmin,
		Status:        domain.UserStatusActive,
		CreatedAt:     now,
		UpdatedAt:     now,
	}
	if err := userRepo.Create(ctx, admin); err != nil {
		return err
	}
	slog.Info("admin account created", "email", cfg.Email)
	return nil
}

func seedProviders(ctx context.Context, repo port.ProviderConfigRepository) {
	displayNames := map[string]string{
		domain.ProviderGitHub:    "GitHub",
		domain.ProviderGoogle:    "Google",
		domain.ProviderGitLab:    "GitLab",
		domain.ProviderGitee:     "Gitee",
		domain.ProviderLinuxDO:   "Linux DO",
		domain.ProviderDiscord:   "Discord",
		domain.ProviderTelegram:  "Telegram",
		domain.ProviderMicrosoft: "Microsoft",
		domain.ProviderApple:     "Apple",
		domain.ProviderQQ:        "QQ",
		domain.ProviderWeChat:    "WeChat",
		domain.ProviderPhone:     "Phone",
	}

	existing, _ := repo.List(ctx)
	have := make(map[string]bool, len(existing))
	for _, pc := range existing {
		have[pc.Provider] = true
	}

	for i, name := range domain.AllProviders() {
		if have[name] {
			continue
		}
		pc := &domain.ProviderConfig{
			ID:          uuid.New(),
			Provider:    name,
			DisplayName: displayNames[name],
			IsEnabled:   false,
			SortOrder:   i,
			CreatedAt:   time.Now().UTC(),
			UpdatedAt:   time.Now().UTC(),
		}
		if err := repo.Upsert(ctx, pc); err != nil {
			slog.Warn("seed provider", "provider", name, "error", err)
		}
	}
}

func seedSettings(ctx context.Context, repo port.SettingsRepository, cfg *config.Config) {
	defaults := map[string]string{
		"site_url":             defaultSiteURL(cfg),
		"github_url":           "https://github.com/Luotianyi-0712/OpenOIDC",
		"contact_info":         "",
		"registration_enabled": "true",
		"registration_email_verification_required": "true",
		"password_login_enabled":                   "true",
		"social_login_enabled":                     "true",
		"social_register_enabled":                  "true",
		"social_binding_enabled":                   "true",
		"captcha_enabled":                          "true",
		"captcha_provider":                         "turnstile",
		"captcha_site_key":                         "",
		"captcha_secret_key":                       "",
		"risk_policy_enabled":                      "true",
		"risk_blocked_ips":                         "",
		"risk_blocked_emails":                      "",
		"risk_blocked_email_domains":               "",
		"risk_blocked_email_patterns":              `^[^@]*[.+][^@]*@`,
		"passkey_enabled":                          "true",
	}
	for key, value := range defaults {
		if _, err := repo.Get(ctx, key); err != nil {
			// Setting does not exist yet; seed the default.
			if err := repo.Upsert(ctx, key, value, ""); err != nil {
				slog.Warn("seed setting", "key", key, "error", err)
			}
		}
	}
}

func defaultSiteURL(cfg *config.Config) string {
	for _, candidate := range []string{cfg.Server.BaseURL, cfg.Server.Issuer} {
		if value := strings.TrimRight(strings.TrimSpace(candidate), "/"); value != "" {
			return value
		}
	}
	return fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
}

func startBackgroundJobs(ctx context.Context, sessionRepo port.SessionRepository, securitySvc *service.SecurityLevelService, socialSvc *service.SocialService, socialSync config.SocialAuthSyncConfig) {
	go func() {
		ticker := time.NewTicker(15 * time.Minute)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				if err := sessionRepo.DeleteExpired(ctx); err != nil {
					slog.Warn("cleanup expired sessions", "error", err)
				}
			}
		}
	}()

	go func() {
		ticker := time.NewTicker(1 * time.Hour)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				if err := securitySvc.RecomputeAll(ctx); err != nil {
					slog.Warn("recompute security levels", "error", err)
				}
			}
		}
	}()

	if socialSync.Enabled && socialSvc != nil {
		interval := socialSync.Interval
		if interval <= 0 {
			interval = time.Hour
		}
		batchSize := socialSync.BatchSize
		if batchSize <= 0 {
			batchSize = 100
		}
		go func() {
			ticker := time.NewTicker(interval)
			defer ticker.Stop()
			for {
				select {
				case <-ctx.Done():
					return
				case <-ticker.C:
					staleBefore := time.Now().UTC().Add(-interval)
					if err := socialSvc.SyncAuthorizationStatus(ctx, batchSize, staleBefore); err != nil {
						slog.Warn("sync social authorization status", "error", err)
					}
				}
			}
		}()
	}
}