// Package identityprovider implements a deliberately small, single-tenant OIDC // authorization-code issuer backed by Feishu's web authorization API. package identityprovider import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/store" "vpn-platform/backend/internal/tlsservice" ) const ( feishuAuthorize = "https://accounts.feishu.cn/open-apis/authen/v1/authorize" feishuToken = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" feishuUserInfo = "https://open.feishu.cn/open-apis/authen/v1/user_info" ) type Provider struct{} func New() *Provider { return &Provider{} } var limits = struct { sync.Mutex hits map[string]struct { count int reset time.Time } }{hits: make(map[string]struct { count int reset time.Time })} // Limit browser/token attempts without retaining request bodies or credentials. func RateLimit() func(*gin.Context) { return func(c *gin.Context) { limits.Lock() if len(limits.hits) > 10000 { for key, item := range limits.hits { if time.Now().After(item.reset) { delete(limits.hits, key) } } if len(limits.hits) > 10000 { limits.Unlock() c.AbortWithStatus(http.StatusTooManyRequests) return } } peer, _, _ := net.SplitHostPort(c.Request.RemoteAddr) if ip := net.ParseIP(peer); ip != nil && ip.IsLoopback() { if realIP := net.ParseIP(c.GetHeader("X-Real-IP")); realIP != nil { peer = realIP.String() } } key := peer + ":" + c.FullPath() item := limits.hits[key] if time.Now().After(item.reset) { item.count = 0 item.reset = time.Now().Add(time.Minute) } item.count++ limits.hits[key] = item blocked := item.count > 40 limits.Unlock() if blocked { c.AbortWithStatus(http.StatusTooManyRequests) } } } // The legacy management port is still reachable over HTTP. Never receive // authorization codes or credentials through it from a remote client. func SecureGateway() gin.HandlerFunc { return func(c *gin.Context) { if c.Request.TLS != nil { return } peer, _, err := net.SplitHostPort(c.Request.RemoteAddr) if err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() && c.GetHeader("X-Forwarded-Proto") == "https" { return } c.AbortWithStatus(http.StatusForbidden) } } func init() { go func() { ticker := time.NewTicker(time.Hour) for range ticker.C { if store.DB != nil { store.DB.Where("expires_at < ?", time.Now().Add(-time.Hour)).Delete(&model.IdentityGrant{}) } } }() } func issuer() (string, error) { status := tlsservice.Status() if !status.Valid || status.RestartRequired || status.Domain == "" { return "", errors.New("需要先启用并验证公网 HTTPS 域名") } return "https://" + status.Domain + "/id", nil } func current() (model.IdentityProvider, error) { var cfg model.IdentityProvider if store.DB == nil { return cfg, errors.New("identity database not initialized") } err := store.DB.First(&cfg, 1).Error return cfg, err } func enabled() (model.IdentityProvider, string, error) { cfg, err := current() if err != nil || !cfg.Enabled || cfg.AppID == "" || cfg.TenantKey == "" || cfg.ClientID == "" || cfg.ClientSecret == "" { return cfg, "", errors.New("身份服务未配置或未启用") } base, err := issuer() return cfg, base, err } // ClientForIssuer is an internal-only bridge to the OpenVPN plugin. It never // exposes the generated confidential-client secret through a management API. func ClientForIssuer(expectedIssuer, clientID string) (string, bool) { cfg, base, err := enabled() if err != nil || base != expectedIssuer || cfg.ClientID != clientID { return "", false } secret, err := store.DecryptSecret(cfg.ClientSecret) return secret, err == nil && secret != "" } func PublicClient() (string, string, bool) { cfg, base, err := enabled() return base, cfg.ClientID, err == nil } func randomToken() (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(raw), nil } func digest(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func saveGrant(kind, token string, data any, duration time.Duration) error { payload, err := json.Marshal(data) if err != nil { return err } return store.DB.Create(&model.IdentityGrant{Kind: kind, Digest: digest(token), Payload: string(payload), ExpiresAt: time.Now().Add(duration)}).Error } func consumeGrant(kind, token string, output any) error { if len(token) < 32 || len(token) > 256 { return errors.New("invalid grant") } return store.DB.Transaction(func(tx *gorm.DB) error { var grant model.IdentityGrant if err := tx.Where("kind = ? AND digest = ? AND consumed = ? AND expires_at > ?", kind, digest(token), false, time.Now()).First(&grant).Error; err != nil { return errors.New("expired or used grant") } result := tx.Model(&model.IdentityGrant{}).Where("id = ? AND consumed = ?", grant.ID, false).Update("consumed", true) if result.Error != nil || result.RowsAffected != 1 { return errors.New("expired or used grant") } if err := json.Unmarshal([]byte(grant.Payload), output); err != nil { return err } return nil }) } func signingKey() (*rsa.PrivateKey, string, error) { dir := filepath.Join(service.BaseDir(), "identity") if err := os.MkdirAll(dir, 0o700); err != nil { return nil, "", err } path := filepath.Join(dir, "signing-key.pem") data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { key, genErr := rsa.GenerateKey(rand.Reader, 3072) if genErr != nil { return nil, "", genErr } encoded, encErr := x509.MarshalPKCS8PrivateKey(key) if encErr != nil { return nil, "", encErr } file, openErr := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if errors.Is(openErr, os.ErrExist) { return signingKey() } if openErr != nil { return nil, "", openErr } _, writeErr := file.Write(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) closeErr := file.Close() if writeErr != nil || closeErr != nil { return nil, "", fmt.Errorf("write signing key: %v %v", writeErr, closeErr) } data, err = os.ReadFile(path) } if err != nil { return nil, "", err } info, err := os.Stat(path) if err != nil || info.Mode().Perm()&0o077 != 0 { return nil, "", errors.New("签名密钥权限不安全,必须为 0600") } block, _ := pem.Decode(data) if block == nil { return nil, "", errors.New("签名密钥无效") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, "", err } key, ok := parsed.(*rsa.PrivateKey) if !ok || key.N.BitLen() < 3072 { return nil, "", errors.New("签名密钥强度不足") } sum := sha256.Sum256(x509.MarshalPKCS1PublicKey(&key.PublicKey)) return key, base64.RawURLEncoding.EncodeToString(sum[:8]), nil } func validateRedirect(raw, domain string) bool { return raw == "https://"+domain+"/oauth2/callback" } func validTenant(value string) bool { return value != "" && len(value) <= 128 && !strings.ContainsAny(value, " \r\n\t") }