请返回 VPN 客户端
浏览器认证步骤已结束。连接状态请以客户端显示为准。
-diff --git a/backend/internal/identityprovider/handlers.go b/backend/internal/identityprovider/handlers.go index 96a9b58..48073be 100644 --- a/backend/internal/identityprovider/handlers.go +++ b/backend/internal/identityprovider/handlers.go @@ -212,6 +212,10 @@ func (p *Provider) FeishuCallback(c *gin.Context) { oidcError(c, 403) return } + if err := saveProfile(base, cfg, user); err != nil { + oidcError(c, 503) + return + } code, err := randomToken() if err != nil { oidcError(c, 503) @@ -223,7 +227,8 @@ func (p *Provider) FeishuCallback(c *gin.Context) { return } // A successful user-info lookup confirms this application's browser scope. - // Do not store user identifiers; reset this evidence when credentials change. + // This timestamp contains no identity; the verified subject and display name + // are stored separately in IdentityProfile. Reset the evidence when credentials change. _ = store.DB.Model(&model.IdentityProvider{}).Where("id = ? AND app_id = ? AND tenant_key = ?", cfg.ID, cfg.AppID, cfg.TenantKey).Update("user_scope_verified_at", time.Now()).Error u, _ := url.Parse(state.Redirect) q := u.Query() @@ -249,6 +254,9 @@ func signIDToken(key *rsa.PrivateKey, kid, base string, cfg model.IdentityProvid 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.Name != "" { + claims["name"] = grant.Name + } if grant.Nonce != "" { claims["nonce"] = grant.Nonce } diff --git a/backend/internal/identityprovider/profile.go b/backend/internal/identityprovider/profile.go new file mode 100644 index 0000000..4cec374 --- /dev/null +++ b/backend/internal/identityprovider/profile.go @@ -0,0 +1,40 @@ +package identityprovider + +import ( + "gorm.io/gorm/clause" + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/service" + "vpn-platform/backend/internal/store" +) + +func saveProfile(base string, cfg model.IdentityProvider, user accessData) error { + return store.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "issuer"}, {Name: "app_id"}, {Name: "subject"}}, + DoUpdates: clause.AssignmentColumns([]string{"name", "updated_at"}), + }).Create(&model.IdentityProfile{Issuer: base, AppID: cfg.AppID, Subject: user.Subject, Name: user.Name}).Error +} + +// ProfileNames returns verified display names only for the current built-in +// application and exact issuer. Never resolve an identity by IP or name. +func ProfileNames(tunnel *model.Tunnel, subjects []string) map[string]string { + names := map[string]string{} + if store.DB == nil || len(subjects) == 0 || tunnel.Protocol != "openvpn" { + return names + } + cfg, err := store.TunnelConfig(tunnel) + if err != nil || service.Str(cfg, "auth_mode") != "oidc" || service.Str(cfg, "oidc_provider") != "builtin" { + return names + } + idp, err := current() + if err != nil || !idp.Enabled { + return names + } + var profiles []model.IdentityProfile + if store.DB.Where("issuer = ? AND app_id = ? AND subject IN ?", service.Str(cfg, "oidc_issuer"), idp.AppID, subjects).Find(&profiles).Error != nil { + return names + } + for _, profile := range profiles { + names[profile.Subject] = profile.Name + } + return names +} diff --git a/backend/internal/identityprovider/profile_test.go b/backend/internal/identityprovider/profile_test.go new file mode 100644 index 0000000..c6840f0 --- /dev/null +++ b/backend/internal/identityprovider/profile_test.go @@ -0,0 +1,55 @@ +package identityprovider + +import ( + "testing" + + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/service" + "vpn-platform/backend/internal/store" +) + +func TestProfileNamesRequireMatchingIssuerAppAndSubject(t *testing.T) { + previous := store.DB + t.Cleanup(func() { + if store.DB != nil { + if db, err := store.DB.DB(); err == nil { + _ = db.Close() + } + } + store.DB = previous + }) + if err := store.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + idp := model.IdentityProvider{ID: 1, Enabled: true, AppID: "cli_current"} + if err := store.DB.Create(&idp).Error; err != nil { + t.Fatal(err) + } + issuer := "https://vpn.example.com/id" + subject := "tenant:ou_123" + if err := saveProfile(issuer, idp, accessData{Subject: subject, Name: "First name"}); err != nil { + t.Fatal(err) + } + if err := saveProfile(issuer, idp, accessData{Subject: subject, Name: "Updated name"}); err != nil { + t.Fatal(err) + } + tunnel := &model.Tunnel{Protocol: "openvpn", Config: service.ConfigJSON(map[string]interface{}{ + "auth_mode": "oidc", "oidc_provider": "builtin", "oidc_issuer": issuer, + })} + if names := ProfileNames(tunnel, []string{subject, "tenant:ou_other"}); len(names) != 1 || names[subject] != "Updated name" { + t.Fatalf("profile did not follow stable subject: %v", names) + } + other := *tunnel + other.Config = service.ConfigJSON(map[string]interface{}{ + "auth_mode": "oidc", "oidc_provider": "builtin", "oidc_issuer": "https://other.example.com/id", + }) + if names := ProfileNames(&other, []string{subject}); len(names) != 0 { + t.Fatalf("profile leaked across issuer: %v", names) + } + if err := store.DB.Model(&idp).Update("app_id", "cli_other").Error; err != nil { + t.Fatal(err) + } + if names := ProfileNames(tunnel, []string{subject}); len(names) != 0 { + t.Fatalf("profile leaked across app: %v", names) + } +} diff --git a/backend/internal/identityprovider/provider_test.go b/backend/internal/identityprovider/provider_test.go index 4cb2a07..6d4adb4 100644 --- a/backend/internal/identityprovider/provider_test.go +++ b/backend/internal/identityprovider/provider_test.go @@ -47,7 +47,7 @@ func TestFeishuExchangeUsesJSONAndRejectsUnexpectedIdentity(t *testing.T) { 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" { + if err != nil || tenant != "tenant_1" || user.Subject != "tenant_1:ou_1" || user.Name != "Alice" { t.Fatalf("identity: %+v %s %v", user, tenant, err) } } @@ -86,7 +86,7 @@ func TestGrantSingleUseAndSignedToken(t *testing.T) { 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"} + grant := codeData{requestState: requestState{ClientID: "opsx-vpn", Redirect: "https://vpn.example.com/oauth2/callback", Nonce: "nonce-1"}, Subject: "tenant:ou_1", Name: "Alice"} if err := saveGrant("code", code, grant, time.Minute); err != nil { t.Fatal(err) } @@ -122,7 +122,7 @@ func TestGrantSingleUseAndSignedToken(t *testing.T) { 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" { + if json.Unmarshal(claimsRaw, &claims) != nil || claims["sub"] != "tenant:ou_1" || claims["name"] != "Alice" || 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 { @@ -130,6 +130,33 @@ func TestGrantSingleUseAndSignedToken(t *testing.T) { } } +func TestIDTokenOmitsMissingDisplayName(t *testing.T) { + t.Setenv("VPN_DATA_DIR", t.TempDir()) + key, kid, err := signingKey() + if err != nil { + t.Fatal(err) + } + token, err := signIDToken(key, kid, "https://vpn.example.com/id", model.IdentityProvider{ClientID: "opsx-vpn"}, codeData{Subject: "tenant:ou_1"}) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatal("invalid JWT") + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatal(err) + } + var claims map[string]any + if json.Unmarshal(body, &claims) != nil || claims["sub"] != "tenant:ou_1" { + t.Fatalf("stable identity missing: %v", claims) + } + if _, present := claims["name"]; present { + t.Fatal("empty display name should not be added to ID token") + } +} + func TestRedirectIsExactAndNoOpenRedirect(t *testing.T) { if !validateRedirect("https://vpn.example.com/oauth2/callback", "vpn.example.com") { t.Fatal("valid callback rejected") diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index c775603..cbe990a 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -28,6 +28,17 @@ type IdentityGrant struct { Consumed bool `gorm:"default:false"` } +// IdentityProfile is display metadata from a verified Feishu user-info response. +// It grants no VPN permissions and contains no upstream access token. +type IdentityProfile struct { + ID uint `gorm:"primaryKey"` + Issuer string `gorm:"uniqueIndex:idx_identity_profile;size:256"` + AppID string `gorm:"uniqueIndex:idx_identity_profile;size:128"` + Subject string `gorm:"uniqueIndex:idx_identity_profile;size:256"` + Name string `gorm:"size:256"` + UpdatedAt time.Time +} + // TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li type TrafficSample struct { ID uint `gorm:"primaryKey" json:"id"` @@ -175,6 +186,7 @@ type AuditLog struct { // VPNSession 在线会话(跨协议统一记录拨号用户的连接状态) // Logan.Li type VPNSession struct { + DisplayName string `gorm:"size:256" json:"display_name,omitempty"` ID uint `gorm:"primaryKey" json:"id"` TunnelID uint `gorm:"index" json:"tunnel_id"` VPNUserID uint `gorm:"index" json:"vpn_user_id"` @@ -191,6 +203,7 @@ type VPNSession struct { // ConnectionLog 连接审计日志(连接/断开/认证失败/配额超额/异常流量) // Logan.Li type ConnectionLog struct { + DisplayName string `gorm:"size:256" json:"display_name,omitempty"` ID uint `gorm:"primaryKey" json:"id"` TunnelID uint `gorm:"index" json:"tunnel_id"` Username string `gorm:"size:128" json:"username"` diff --git a/backend/internal/service/openvpn/auth_result.go b/backend/internal/service/openvpn/auth_result.go index 07a66eb..05615bf 100644 --- a/backend/internal/service/openvpn/auth_result.go +++ b/backend/internal/service/openvpn/auth_result.go @@ -2,16 +2,20 @@ package openvpn // This page is intentionally outcome-neutral: the auth component can render its // end template for different outcomes. Only the VPN client knows connection state. +// Never reflect callback parameters or user identity into this page. const authResultPage = `
-浏览器认证步骤已结束。连接状态请以客户端显示为准。
-浏览器认证步骤已结束,请在 VPN 客户端查看连接状态。
+