Files
opsx-vpn/backend/internal/identityprovider/provider.go
T

260 lines
7.2 KiB
Go

// 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")
}