Add opt-in built-in Feishu OIDC issuer for OpenVPN

This commit is contained in:
opsx committed 2026-10-22 09:47:54 +08:00
1 parent 6ca609fde4
commit 63cdda4ae1
23 files changed
+1244 -17

No files matched your search

+12
View File
@@ -22,6 +22,7 @@ import (
"vpn-platform/backend/internal/binmgr"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/identityprovider"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/service/custom"
@@ -213,6 +214,14 @@ func requirePasswordChanged() gin.HandlerFunc {
func (s *Server) RegisterRoutes(r *gin.Engine) {
s.router = r
r.Use(securityHeaders())
idp := identityprovider.New()
id := r.Group("/id", identityprovider.SecureGateway(), identityprovider.RateLimit())
id.GET("/.well-known/openid-configuration", idp.Discovery)
id.GET("/jwks", idp.JWKS)
id.GET("/authorize", idp.Authorize)
id.GET("/feishu/callback", idp.FeishuCallback)
id.POST("/token", idp.Token)
id.GET("/userinfo", idp.UserInfo)
api := r.Group("/api")
api.POST("/login", s.login)
api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
@@ -302,6 +311,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
admin.GET("/system/env", s.systemEnv)
admin.POST("/system/fix", s.systemFix)
admin.GET("/system/tls", s.tlsStatus)
admin.GET("/system/identity", idp.Status)
admin.PUT("/system/identity", idp.Configure)
admin.POST("/system/identity/gateway", identityprovider.SecureGateway(), s.identityGateway)
admin.PUT("/system/tls/domain", s.setTLSDomain)
admin.PUT("/system/tls/mode", s.setTLSMode)
admin.GET("/system/tls/nginx-preview", s.nginxPreview)
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/nginxgateway"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
"vpn-platform/backend/internal/tlsservice"
)
// Only a single OpenVPN WebAuth instance may own /oauth2/ on the management
// hostname. Never route an arbitrary user-supplied address to a local port.
func authGatewayPort(domain string) (int, error) {
var tunnels []model.Tunnel
if err := store.DB.Where("protocol = ? AND state = ?", "openvpn", "running").Find(&tunnels).Error; err != nil {
return 0, err
}
port := 0
for _, tunnel := range tunnels {
cfg, err := store.TunnelConfig(&tunnel)
if err != nil {
return 0, err
}
if service.Str(cfg, "auth_mode") != "oidc" || !strings.EqualFold(strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/"), "https://"+domain) {
continue
}
if port != 0 {
return 0, &gatewayConflict{}
}
port = int(service.Num(cfg, "oidc_http_port", 0))
}
return port, nil
}
type gatewayConflict struct{}
func (*gatewayConflict) Error() string {
return "当前域名存在多条运行中的 OIDC 专线,请先保留一条"
}
func (s *Server) identityGateway(c *gin.Context) {
status := tlsservice.Status()
if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成 Nginx HTTPS 入口配置"})
return
}
port, err := authGatewayPort(status.Domain)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if err := nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "identity_gateway_updated", status.Domain, "")
c.JSON(200, gin.H{"ok": true, "identity_route": true, "auth_route": port != 0, "auth_port": port})
}
@@ -11,6 +11,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/identityprovider"
"vpn-platform/backend/internal/nginxgateway"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/tlsservice"
@@ -37,10 +38,15 @@ func (s *Server) openvpnOIDCReadiness(c *gin.Context) {
}
}
tls := tlsservice.Status()
builtInIssuer, builtInClientID, builtInEnabled := identityprovider.PublicClient()
port, _ := authGatewayPort(tls.Domain)
issuerRoute, callbackRoute := nginxgateway.AuthRouteReady(port)
c.JSON(http.StatusOK, gin.H{
"component_installed": path != "", "component_version": version,
"https_management_ready": tls.Mode == "nginx" && tls.Valid && nginxgateway.Stage() == "https",
"management_domain": tls.Domain,
"authentication_entry_ready": false, // Depends on a distinct callback site and a configured provider.
"authentication_entry_ready": port > 0 && callbackRoute,
"built_in_issuer": builtInIssuer, "built_in_client_id": builtInClientID,
"built_in_enabled": builtInEnabled, "built_in_gateway_ready": issuerRoute,
})
}
+114
View File
@@ -0,0 +1,114 @@
package identityprovider
import (
"net"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"vpn-platform/backend/internal/nginxgateway"
"vpn-platform/backend/internal/store"
)
func (p *Provider) Status(c *gin.Context) {
cfg, err := current()
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(500, gin.H{"error": "读取身份配置失败"})
return
}
base, tlsError := issuer()
if tlsError != nil {
base = ""
}
issuerRoute, _ := nginxgateway.AuthRouteReady(0)
callback, vpnCallback := "", ""
if base != "" {
callback, vpnCallback = base+"/feishu/callback", strings.TrimSuffix(base, "/id")+"/oauth2/callback"
}
c.JSON(200, gin.H{
"enabled": cfg.Enabled, "app_id": cfg.AppID, "app_secret_configured": cfg.AppSecret != "",
"tenant_key": cfg.TenantKey, "client_id": cfg.ClientID, "client_secret_configured": cfg.ClientSecret != "",
"issuer": base, "feishu_callback": callback, "vpn_callback": vpnCallback,
"tls_ready": tlsError == nil, "gateway_ready": issuerRoute,
})
}
func (p *Provider) Configure(c *gin.Context) {
if c.Request.TLS == nil {
peer, _, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil || net.ParseIP(peer) == nil || !net.ParseIP(peer).IsLoopback() || c.GetHeader("X-Forwarded-Proto") != "https" {
c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口保存飞书凭据"})
return
}
}
var req struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
TenantKey string `json:"tenant_key"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "配置格式错误"})
return
}
req.AppID = strings.TrimSpace(req.AppID)
req.TenantKey = strings.TrimSpace(req.TenantKey)
if len(req.AppID) > 128 || strings.ContainsAny(req.AppID, " \r\n\t") || (req.Enabled && (req.AppID == "" || !validTenant(req.TenantKey))) {
c.JSON(400, gin.H{"error": "请输入有效的飞书 App ID 和租户标识"})
return
}
if req.Enabled {
if _, err := issuer(); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
}
cfg, err := current()
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(500, gin.H{"error": "读取身份配置失败"})
return
}
if req.AppSecret != "" {
if len(req.AppSecret) > 512 {
c.JSON(400, gin.H{"error": "密钥过长"})
return
}
cfg.AppSecret, err = store.EncryptSecret(req.AppSecret)
if err != nil {
c.JSON(500, gin.H{"error": "密钥保存失败"})
return
}
}
if req.Enabled && cfg.AppSecret == "" {
c.JSON(400, gin.H{"error": "飞书 App Secret 未配置"})
return
}
var newClientSecret string
if cfg.ClientID == "" {
newClientSecret, err = randomToken()
if err != nil {
c.JSON(500, gin.H{"error": "凭据生成失败"})
return
}
cfg.ClientID = "opsx-vpn"
cfg.ClientSecret, err = store.EncryptSecret(newClientSecret)
if err != nil {
c.JSON(500, gin.H{"error": "凭据保存失败"})
return
}
}
if req.Enabled {
if _, _, err := signingKey(); err != nil {
c.JSON(503, gin.H{"error": "签名密钥初始化失败"})
return
}
}
cfg.ID, cfg.AppID, cfg.TenantKey, cfg.Enabled = 1, req.AppID, req.TenantKey, req.Enabled
if err := store.DB.Save(&cfg).Error; err != nil {
c.JSON(500, gin.H{"error": "身份配置保存失败"})
return
}
store.Audit(c.GetString("username"), "identity_provider_configured", "feishu", "")
c.JSON(http.StatusOK, gin.H{"ok": true, "client_id": cfg.ClientID})
}
@@ -0,0 +1,57 @@
package identityprovider
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/store"
)
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
if err := store.Init(dir); err != nil {
t.Fatal(err)
}
gin.SetMode(gin.TestMode)
server := gin.New()
p := New()
server.PUT("/config", func(c *gin.Context) { c.Set("username", "admin"); p.Configure(c) })
server.GET("/config", p.Status)
body := []byte(`{"app_id":"cli_test","app_secret":"feishu-private-value","tenant_key":"tenant_1","enabled":false}`)
resp := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body))
request.RemoteAddr = "127.0.0.1:12345"
request.Header.Set("X-Forwarded-Proto", "https")
server.ServeHTTP(resp, request)
if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") {
t.Fatalf("unsafe response: %d %s", resp.Code, resp.Body.String())
}
rejected := httptest.NewRecorder()
server.ServeHTTP(rejected, httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body)))
if rejected.Code != http.StatusForbidden {
t.Fatal("plaintext management request accepted credentials")
}
cfg, err := current()
if err != nil || cfg.AppSecret == "feishu-private-value" {
t.Fatal("app secret stored in plaintext")
}
plain, err := store.DecryptSecret(cfg.AppSecret)
if err != nil || plain != "feishu-private-value" {
t.Fatal("app secret not recoverable")
}
var saved map[string]any
if json.Unmarshal(resp.Body.Bytes(), &saved) != nil || saved["client_id"] == "" || saved["client_secret"] != nil {
t.Fatal("client secret must remain server-side")
}
resp = httptest.NewRecorder()
server.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/config", nil))
if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") || strings.Contains(resp.Body.String(), cfg.ClientSecret) {
t.Fatal("secret leaked from status")
}
}
@@ -0,0 +1,329 @@
package identityprovider
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
type requestState struct {
Redirect string `json:"redirect"`
ClientID string `json:"client_id"`
State string `json:"state"`
Nonce string `json:"nonce"`
Challenge string `json:"challenge"`
}
type codeData struct {
requestState
Subject string `json:"subject"`
Name string `json:"name"`
}
type accessData struct {
Subject string `json:"sub"`
Name string `json:"name"`
}
func noStore(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Header("Pragma", "no-cache") }
func oidcError(c *gin.Context, status int) {
noStore(c)
c.JSON(status, gin.H{"error": "invalid_request"})
}
func (p *Provider) Discovery(c *gin.Context) {
_, base, err := enabled()
if err != nil {
oidcError(c, http.StatusNotFound)
return
}
noStore(c)
c.JSON(200, gin.H{
"issuer": base, "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token",
"userinfo_endpoint": base + "/userinfo", "jwks_uri": base + "/jwks",
"response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid"},
"grant_types_supported": []string{"authorization_code"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"code_challenge_methods_supported": []string{"S256"},
})
}
func (p *Provider) JWKS(c *gin.Context) {
if _, _, err := enabled(); err != nil {
oidcError(c, 404)
return
}
key, kid, err := signingKey()
if err != nil {
oidcError(c, 503)
return
}
noStore(c)
c.JSON(200, gin.H{"keys": []gin.H{{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid,
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())}}})
}
func (p *Provider) Authorize(c *gin.Context) {
cfg, base, err := enabled()
if err != nil {
oidcError(c, 503)
return
}
redirect := c.Query("redirect_uri")
domain := strings.TrimSuffix(strings.TrimPrefix(base, "https://"), "/id")
if !validateRedirect(redirect, domain) || c.Query("client_id") != cfg.ClientID || c.Query("response_type") != "code" || !strings.Contains(" "+c.Query("scope")+" ", " openid ") {
oidcError(c, 400)
return
}
challenge, method := c.Query("code_challenge"), c.Query("code_challenge_method")
if (challenge != "" && (method != "S256" || len(challenge) != 43)) || (challenge == "" && method != "") || len(c.Query("state")) > 1024 || len(c.Query("nonce")) > 1024 {
oidcError(c, 400)
return
}
state, err := randomToken()
if err != nil {
oidcError(c, 503)
return
}
pending := requestState{Redirect: redirect, ClientID: cfg.ClientID, State: c.Query("state"), Nonce: c.Query("nonce"), Challenge: challenge}
if err := saveGrant("state", state, pending, 5*time.Minute); err != nil {
oidcError(c, 503)
return
}
upstream := feishuAuthorize + "?" + url.Values{
"client_id": {cfg.AppID}, "redirect_uri": {base + "/feishu/callback"}, "response_type": {"code"}, "scope": {"user_profile"}, "state": {state},
}.Encode()
noStore(c)
c.Redirect(http.StatusFound, upstream)
}
var upstreamClient = &http.Client{Timeout: 8 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
func postFeishuToken(ctx context.Context, appID, appSecret, code, callback string) (string, error) {
payload, _ := json.Marshal(gin.H{"grant_type": "authorization_code", "client_id": appID, "client_secret": appSecret, "code": code, "redirect_uri": callback})
req, err := http.NewRequestWithContext(ctx, "POST", feishuToken, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
response, err := upstreamClient.Do(req)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return "", errors.New("upstream token rejected")
}
var data struct {
Code *int `json:"code"`
AccessToken string `json:"access_token"`
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&data); err != nil || data.Code == nil || *data.Code != 0 {
return "", errors.New("upstream token invalid")
}
if data.AccessToken != "" {
return data.AccessToken, nil
}
if data.Data.AccessToken != "" {
return data.Data.AccessToken, nil
}
return "", errors.New("upstream access token missing")
}
func fetchFeishuUser(ctx context.Context, token string) (accessData, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", feishuUserInfo, nil)
if err != nil {
return accessData{}, "", err
}
req.Header.Set("Authorization", "Bearer "+token)
response, err := upstreamClient.Do(req)
if err != nil {
return accessData{}, "", err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return accessData{}, "", errors.New("upstream user rejected")
}
var result struct {
Code *int `json:"code"`
Data struct {
OpenID string `json:"open_id"`
TenantKey string `json:"tenant_key"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil || result.Code == nil || *result.Code != 0 || result.Data.OpenID == "" || result.Data.TenantKey == "" {
return accessData{}, "", errors.New("upstream user missing stable identity")
}
return accessData{Subject: result.Data.TenantKey + ":" + result.Data.OpenID, Name: result.Data.Name}, result.Data.TenantKey, nil
}
func (p *Provider) FeishuCallback(c *gin.Context) {
cfg, base, err := enabled()
if err != nil {
oidcError(c, 503)
return
}
var state requestState
if err := consumeGrant("state", c.Query("state"), &state); err != nil || c.Query("error") != "" || c.Query("code") == "" || len(c.Query("code")) > 2048 {
oidcError(c, 400)
return
}
secret, err := store.DecryptSecret(cfg.AppSecret)
if err != nil {
oidcError(c, 503)
return
}
token, err := postFeishuToken(c.Request.Context(), cfg.AppID, secret, c.Query("code"), base+"/feishu/callback")
if err != nil {
oidcError(c, 502)
return
}
user, tenant, err := fetchFeishuUser(c.Request.Context(), token)
if err != nil {
oidcError(c, 502)
return
}
if tenant != cfg.TenantKey {
oidcError(c, 403)
return
}
code, err := randomToken()
if err != nil {
oidcError(c, 503)
return
}
grant := codeData{requestState: state, Subject: user.Subject, Name: user.Name}
if err := saveGrant("code", code, grant, 2*time.Minute); err != nil {
oidcError(c, 503)
return
}
u, _ := url.Parse(state.Redirect)
q := u.Query()
q.Set("code", code)
if state.State != "" {
q.Set("state", state.State)
}
u.RawQuery = q.Encode()
noStore(c)
c.Redirect(http.StatusFound, u.String())
}
func bearer(c *gin.Context, cfg model.IdentityProvider) bool {
id, secret, ok := c.Request.BasicAuth()
if !ok {
id, secret = c.PostForm("client_id"), c.PostForm("client_secret")
}
stored, err := store.DecryptSecret(cfg.ClientSecret)
return err == nil && subtle.ConstantTimeCompare([]byte(id), []byte(cfg.ClientID)) == 1 && subtle.ConstantTimeCompare([]byte(secret), []byte(stored)) == 1
}
func signIDToken(key *rsa.PrivateKey, kid, base string, cfg model.IdentityProvider, grant codeData) (string, error) {
now := time.Now().Unix()
header, _ := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": kid})
claims := gin.H{"iss": base, "aud": cfg.ClientID, "sub": grant.Subject, "iat": now, "exp": now + 600, "auth_time": now}
if grant.Nonce != "" {
claims["nonce"] = grant.Nonce
}
body, _ := json.Marshal(claims)
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(body)
hash := sha256.Sum256([]byte(unsigned))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
if err != nil {
return "", err
}
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func (p *Provider) Token(c *gin.Context) {
cfg, base, err := enabled()
if err != nil {
oidcError(c, 503)
return
}
noStore(c)
if !bearer(c, cfg) || c.PostForm("grant_type") != "authorization_code" {
c.JSON(401, gin.H{"error": "invalid_client"})
return
}
var grant codeData
if err := consumeGrant("code", c.PostForm("code"), &grant); err != nil {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
if grant.ClientID != cfg.ClientID || grant.Redirect != c.PostForm("redirect_uri") {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
if grant.Challenge != "" {
verifier := c.PostForm("code_verifier")
if len(verifier) < 43 || len(verifier) > 128 {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
hash := sha256.Sum256([]byte(verifier))
if subtle.ConstantTimeCompare([]byte(grant.Challenge), []byte(base64.RawURLEncoding.EncodeToString(hash[:]))) != 1 {
c.JSON(400, gin.H{"error": "invalid_grant"})
return
}
}
key, kid, err := signingKey()
if err != nil {
oidcError(c, 503)
return
}
idToken, err := signIDToken(key, kid, base, cfg, grant)
if err != nil {
oidcError(c, 503)
return
}
access, err := randomToken()
if err != nil || saveGrant("access", access, accessData{Subject: grant.Subject, Name: grant.Name}, 10*time.Minute) != nil {
oidcError(c, 503)
return
}
c.JSON(200, gin.H{"access_token": access, "id_token": idToken, "token_type": "Bearer", "expires_in": 600, "scope": "openid"})
}
func (p *Provider) UserInfo(c *gin.Context) {
if _, _, err := enabled(); err != nil {
oidcError(c, 503)
return
}
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token == c.GetHeader("Authorization") || len(token) < 32 {
oidcError(c, 401)
return
}
var grant model.IdentityGrant
if err := store.DB.Where("kind = ? AND digest = ? AND expires_at > ?", "access", digest(token), time.Now()).First(&grant).Error; err != nil {
oidcError(c, 401)
return
}
var user accessData
if json.Unmarshal([]byte(grant.Payload), &user) != nil {
oidcError(c, 401)
return
}
noStore(c)
c.JSON(200, gin.H{"sub": user.Subject, "name": user.Name})
}
@@ -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")
}
@@ -0,0 +1,134 @@
package identityprovider
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestFeishuExchangeUsesJSONAndRejectsUnexpectedIdentity(t *testing.T) {
previous := upstreamClient
defer func() { upstreamClient = previous }()
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() == feishuToken {
if req.Header.Get("Content-Type") != "application/json" {
t.Fatal("Feishu token exchange must use JSON")
}
var body map[string]string
if json.NewDecoder(req.Body).Decode(&body) != nil || body["client_id"] != "app-id" || body["redirect_uri"] != "https://vpn.example.com/id/feishu/callback" {
t.Fatal("wrong Feishu exchange request")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"feishu-token"}`)), Header: make(http.Header)}, nil
}
if req.URL.String() != feishuUserInfo || req.Header.Get("Authorization") != "Bearer feishu-token" {
t.Fatal("unexpected Feishu user request")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"open_id":"ou_1","tenant_key":"tenant_1","name":"Alice"}}`)), Header: make(http.Header)}, nil
})}
token, err := postFeishuToken(context.Background(), "app-id", "app-secret", "feishu-code", "https://vpn.example.com/id/feishu/callback")
if err != nil || token != "feishu-token" {
t.Fatalf("exchange: %q %v", token, err)
}
user, tenant, err := fetchFeishuUser(context.Background(), token)
if err != nil || tenant != "tenant_1" || user.Subject != "tenant_1:ou_1" {
t.Fatalf("identity: %+v %s %v", user, tenant, err)
}
}
func TestFeishuExchangeRejectsMalformedSuccess(t *testing.T) {
previous := upstreamClient
defer func() { upstreamClient = previous }()
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"access_token":"fake","data":{"open_id":"ou_1","tenant_key":"tenant_1"}}`)), Header: make(http.Header)}, nil
})}
if _, err := postFeishuToken(context.Background(), "id", "secret", "code", "https://vpn.example.com/id/feishu/callback"); err == nil {
t.Fatal("missing Feishu success code accepted")
}
if _, _, err := fetchFeishuUser(context.Background(), "fake"); err == nil {
t.Fatal("missing Feishu user response code accepted")
}
}
func TestGrantSingleUseAndSignedToken(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
if err := store.Init(dir); err != nil {
t.Fatal(err)
}
code, err := randomToken()
if err != nil {
t.Fatal(err)
}
grant := codeData{requestState: requestState{ClientID: "opsx-vpn", Redirect: "https://vpn.example.com/oauth2/callback", Nonce: "nonce-1"}, Subject: "tenant:ou_1"}
if err := saveGrant("code", code, grant, time.Minute); err != nil {
t.Fatal(err)
}
var used codeData
if err := consumeGrant("code", code, &used); err != nil {
t.Fatal(err)
}
if err := consumeGrant("code", code, &used); err == nil {
t.Fatal("grant replay accepted")
}
key, kid, err := signingKey()
if err != nil {
t.Fatal(err)
}
jwt, err := signIDToken(key, kid, "https://vpn.example.com/id", model.IdentityProvider{ClientID: "opsx-vpn"}, used)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
t.Fatal("invalid JWT")
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatal(err)
}
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], signature); err != nil {
t.Fatal(err)
}
claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
var claims map[string]any
if json.Unmarshal(claimsRaw, &claims) != nil || claims["sub"] != "tenant:ou_1" || claims["nonce"] != "nonce-1" || claims["aud"] != "opsx-vpn" {
t.Fatalf("unexpected claims: %v", claims)
}
if info, err := filepath.Glob(filepath.Join(dir, "identity", "*.pem")); err != nil || len(info) != 1 {
t.Fatal("missing signing key")
}
}
func TestRedirectIsExactAndNoOpenRedirect(t *testing.T) {
if !validateRedirect("https://vpn.example.com/oauth2/callback", "vpn.example.com") {
t.Fatal("valid callback rejected")
}
for _, bad := range []string{"http://vpn.example.com/oauth2/callback", "https://vpn.example.com.evil/oauth2/callback", "https://vpn.example.com/oauth2/callback/extra", "https://vpn.example.com/oauth2/callback?next=evil"} {
if validateRedirect(bad, "vpn.example.com") {
t.Errorf("accepted callback %q", bad)
}
}
if err := consumeGrant("state", "short", new(requestState)); err == nil {
t.Fatal("accepted short state")
}
}
+24
View File
@@ -3,6 +3,30 @@ package model
import "time"
// IdentityProvider is the opt-in, single-tenant Feishu OIDC issuer configuration.
// Credentials are encrypted with the platform key and never serialized in API responses.
type IdentityProvider struct {
ID uint `gorm:"primaryKey" json:"-"`
Enabled bool `json:"enabled"`
AppID string `gorm:"size:128" json:"app_id"`
AppSecret string `gorm:"type:text" json:"-"`
TenantKey string `gorm:"size:128" json:"tenant_key"`
ClientID string `gorm:"size:128" json:"client_id"`
ClientSecret string `gorm:"type:text" json:"-"`
UpdatedAt time.Time `json:"updated_at"`
}
// IdentityGrant stores only a hash of each browser state, authorization code,
// or access token. A consumed code cannot be exchanged a second time.
type IdentityGrant struct {
ID uint `gorm:"primaryKey"`
Kind string `gorm:"size:12;index"`
Digest string `gorm:"size:64;uniqueIndex"`
Payload string `gorm:"type:text"`
ExpiresAt time.Time `gorm:"index"`
Consumed bool `gorm:"default:false"`
}
// TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li
type TrafficSample struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -40,10 +40,19 @@ func routePattern(path string) string {
// Render returns the HTTP-01 bootstrap site and the HTTPS site separately.
// Enable the second site only after Certbot has issued the certificate.
func Render(domain string, routes gin.RoutesInfo) (httpSite, httpsSite string, err error) {
return RenderWithAuth(domain, routes, 0)
}
// RenderWithAuth optionally exposes a single local OpenVPN browser-auth service.
// All other unknown paths retain the existing 444 catch-all.
func RenderWithAuth(domain string, routes gin.RoutesInfo, authPort int) (httpSite, httpsSite string, err error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if !tlsservice.ValidDomain(domain) {
return "", "", fmt.Errorf("请输入有效的单个完整域名")
}
if authPort != 0 && (authPort < 1024 || authPort > 65535) {
return "", "", fmt.Errorf("认证端口无效")
}
httpSite = fmt.Sprintf(`# Install this HTTP site first. Certbot webroot: /var/lib/opsx-vpn/acme
server {
listen 80 default_server;
@@ -84,10 +93,22 @@ server {
}
b.WriteString(" location ~ ^/tunnels/[0-9]+/edit$ {\n" + upstream + " }\n")
b.WriteString(" location ~ ^/assets/[A-Za-z0-9_.-]+\\.(js|css|svg|png|woff2)$ {\n" + upstream + " }\n")
// Public issuer endpoints are fixed: unknown /id/* requests still get 444.
for _, path := range []string{"/id/.well-known/openid-configuration", "/id/jwks", "/id/authorize", "/id/feishu/callback", "/id/token", "/id/userinfo"} {
fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream)
}
if authPort != 0 {
fmt.Fprintf(&b, " location ^~ /oauth2/ {\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:%d;\n }\n", authPort)
}
seen := make(map[string]bool)
var paths []string
for _, route := range routes {
// The issuer locations above are deliberately enumerated. Gin also
// reports them as routes; emitting them twice makes nginx -t fail.
if strings.HasPrefix(route.Path, "/id/") {
continue
}
if !validRoute(route.Path) || seen[route.Path] {
continue
}
@@ -64,3 +64,25 @@ func TestRenderRejectsInjectedDomainAndRoute(t *testing.T) {
t.Fatal("injected route accepted")
}
}
func TestRenderIdentityAndSingleAuthPort(t *testing.T) {
_, site, err := RenderWithAuth("vpn.example.com", gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}, 19090)
if err != nil {
t.Fatal(err)
}
for _, route := range []string{"location = /id/.well-known/openid-configuration", "location = /id/feishu/callback", "location = /id/token", "location ^~ /oauth2/", "proxy_pass http://127.0.0.1:19090;", "location / { return 444; }"} {
if !strings.Contains(site, route) {
t.Errorf("identity route missing: %s", route)
}
}
if strings.Contains(site, "location ^~ /id/") || strings.Contains(site, "location ^~ /.well-known/") {
t.Fatal("identity routes must stay allowlisted")
}
if _, _, err := RenderWithAuth("vpn.example.com", nil, 80); err == nil {
t.Fatal("invalid auth port accepted")
}
_, withoutAuth, err := Render("vpn.example.com", nil)
if err != nil || strings.Contains(withoutAuth, "location ^~ /oauth2/") {
t.Fatal("auth component exposed without a configured port")
}
}
+52
View File
@@ -46,6 +46,58 @@ func Stage() string {
return "http"
}
// AuthRouteReady reflects the installed managed site, not actual Feishu reachability.
func AuthRouteReady(port int) (bool, bool) {
data, err := os.ReadFile(sitePath)
if err != nil {
return false, false
}
return strings.Contains(string(data), "location = /id/.well-known/openid-configuration"),
port > 0 && strings.Contains(string(data), fmt.Sprintf("proxy_pass http://127.0.0.1:%d;", port))
}
// RefreshAuth updates only the managed site after validation; a bad config is
// rolled back before returning. It never provisions a certificate or domain.
func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error {
if Running() {
return errors.New("公网入口操作正在进行")
}
if err := checkSites(); err != nil {
return err
}
if Stage() != "https" {
return errors.New("请先启用 HTTPS 站点")
}
previous, err := os.ReadFile(sitePath)
if err != nil {
return err
}
if !strings.Contains(string(previous), "server_name "+domain+";") {
return errors.New("现有站点域名与配置不匹配")
}
httpSite, httpsSite, err := nginxconfig.RenderWithAuth(domain, routes, authPort)
if err != nil {
return err
}
if err := writeSite(httpSite + httpsSite); err != nil {
return err
}
rollback := func() {
_ = writeSite(string(previous))
_ = command(func(string) {}, 15*time.Second, "nginx", "-t")
_ = command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx")
}
if err := command(func(string) {}, 15*time.Second, "nginx", "-t"); err != nil {
rollback()
return err
}
if err := command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx"); err != nil {
rollback()
return err
}
return nil
}
func command(emit func(string), timeout time.Duration, name string, args ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
+4
View File
@@ -14,6 +14,7 @@ import (
"syscall"
"time"
"vpn-platform/backend/internal/identityprovider"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
@@ -103,6 +104,9 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
if service.Str(cfg, "oidc_client_secret") == "" {
cfg["oidc_client_secret"] = old["oidc_client_secret"]
}
if secret, ok := identityprovider.ClientForIssuer(service.Str(cfg, "oidc_issuer"), service.Str(cfg, "oidc_client_id")); ok {
cfg["oidc_client_secret"] = secret
}
if service.Str(cfg, "oidc_client_secret") == "" {
return fmt.Errorf("oidc_client_secret 必填;请从 OIDC 发行者对应的客户端凭据中复制 Client Secret")
}
+1 -1
View File
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
}
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}); err != nil {
return err
}
DB = db