Bind Feishu identity to OpenVPN sessions and refine management UI

This commit is contained in:
opsx committed 2026-10-22 23:24:41 +08:00
1 parent 347799cb1e
commit 8fd8c0cd73
27 files changed
+1556 -1011

No files matched your search

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