Add opt-in built-in Feishu OIDC issuer for OpenVPN
This commit is contained in:
1 parent
6ca609fde4
commit
63cdda4ae1
23 files changed
+1244
-17
No files matched your search
@@ -0,0 +1,259 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user