275 lines
7.6 KiB
Go
275 lines
7.6 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() {
|
|
proto := c.GetHeader("X-Forwarded-Proto")
|
|
scheme := tlsservice.Status().Scheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
if proto == scheme {
|
|
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.RestartRequired || status.Domain == "" || status.PublicBase == "" {
|
|
return "", errors.New("需要先配置并启用公网入口")
|
|
}
|
|
scheme := status.Scheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
if scheme == "https" && !status.Valid {
|
|
return "", errors.New("需要先启用并验证公网 HTTPS 域名")
|
|
}
|
|
return status.PublicBase + "/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, publicBase string) bool {
|
|
publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/")
|
|
return publicBase != "" && raw == publicBase+"/oauth2/callback"
|
|
}
|
|
|
|
func validTenant(value string) bool {
|
|
return value != "" && len(value) <= 128 && !strings.ContainsAny(value, " \r\n\t")
|
|
}
|