Bind Feishu identity to OpenVPN sessions and refine management UI
This commit is contained in:
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")
|
||||
|
||||
Reference in new issue
Block a user