From 57168d4cb41775c196be3a0a690bb287fb987c91 Mon Sep 17 00:00:00 2001 From: aiden Date: Wed, 28 Oct 2026 22:35:13 +0800 Subject: [PATCH] feat(gcwork): match existing users through Casdoor OAuth bindings Users who signed in to NewAPI through a custom Casdoor OAuth provider are bound by user_oauth_bindings, not oidc_id, so ensure would have created a second account for them. GCWORK_OAUTH_PROVIDERS names those providers: ensure now finds the subject through their bindings, adopts an unbound common user whose username equals the Casdoor username, and binds new users to the first provider so NewAPI web sign-in reaches the same account. Co-Authored-By: Claude Opus 5.5 --- controller/gcwork.go | 112 +++++++++++++++++++++++++++++++++----- controller/gcwork_test.go | 91 +++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 13 deletions(-) diff --git a/controller/gcwork.go b/controller/gcwork.go index 0bc17c6b7c18..b15f5b6d1e79 100644 --- a/controller/gcwork.go +++ b/controller/gcwork.go @@ -4,7 +4,10 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" + "os" "strconv" + "strings" "sync" "github.com/QuantumNous/new-api/common" @@ -25,8 +28,7 @@ import ( const gcworkExternalIDMaxLength = 128 // gcworkEnsureLock serializes provisioning on this node; across nodes the -// unique username index decides, because every request for one external ID -// tries the same username candidates in the same order. +// unique OAuth binding and username indexes decide. var gcworkEnsureLock sync.Mutex type gcworkEnsureUserRequest struct { @@ -36,23 +38,84 @@ type gcworkEnsureUserRequest struct { Email string `json:"email"` } +// gcworkOAuthProviders resolves GCWORK_OAUTH_PROVIDERS, the comma-separated +// slugs of the custom OAuth providers that sign users in through the same +// Casdoor as GC Work. The first one receives the binding of a new user. +func gcworkOAuthProviders() ([]*model.CustomOAuthProvider, error) { + providers := []*model.CustomOAuthProvider{} + for slug := range strings.SplitSeq(os.Getenv("GCWORK_OAUTH_PROVIDERS"), ",") { + if slug = strings.TrimSpace(slug); slug == "" { + continue + } + provider, err := model.GetCustomOAuthProviderBySlug(slug) + if err != nil { + return nil, fmt.Errorf("GCWORK_OAUTH_PROVIDERS names unknown provider %q: %w", slug, err) + } + providers = append(providers, provider) + } + return providers, nil +} + // GcworkEnsureUser returns the user bound to a Casdoor subject, creating it in -// the default group with the default quota on first use. The subject is stored -// as the OIDC id, so NewAPI's own OIDC sign-in against the same Casdoor issuer -// reaches the same account. +// the default group with the default quota on first use. With +// GCWORK_OAUTH_PROVIDERS set, the subject is matched against those providers' +// bindings, an unbound user whose username equals the Casdoor username is +// adopted, and a new user gets a binding to the first provider, so NewAPI web +// sign-in through Casdoor reaches the same account. Without it, the subject is +// stored as the OIDC id. func GcworkEnsureUser(c *gin.Context) { var req gcworkEnsureUserRequest if err := common.DecodeJson(c.Request.Body, &req); err != nil || req.ExternalID == "" || len(req.ExternalID) > gcworkExternalIDMaxLength { common.ApiErrorT(c, "Invalid parameters") return } + providers, err := gcworkOAuthProviders() + if err != nil { + common.ApiError(c, err) + return + } gcworkEnsureLock.Lock() defer gcworkEnsureLock.Unlock() - if user, err := gcworkUserByExternalID(req.ExternalID); err != nil || user != nil { + if user, err := gcworkUserByExternalID(providers, req.ExternalID); err != nil || user != nil { gcworkEnsureResult(c, user, err) return } + if len(providers) > 0 && req.Username != "" { + var existing model.User + err := model.DB.Where("username = ?", req.Username).First(&existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiError(c, err) + return + } + if err == nil && existing.OidcId == "" && existing.Role < common.RoleAdminUser { + var bound int64 + ids := make([]int, len(providers)) + for i, provider := range providers { + ids[i] = provider.Id + } + if err := model.DB.Model(&model.UserOAuthBinding{}).Where("user_id = ? AND provider_id IN ?", existing.Id, ids).Count(&bound).Error; err != nil { + common.ApiError(c, err) + return + } + if bound == 0 { + binding := model.UserOAuthBinding{UserId: existing.Id, ProviderId: providers[0].Id, ProviderUserId: req.ExternalID} + if err := model.CreateUserOAuthBinding(&binding); err != nil { + // Another node may have bound the subject meanwhile. + user, lookupErr := gcworkUserByExternalID(providers, req.ExternalID) + if lookupErr == nil && user == nil { + lookupErr = err + } + gcworkEnsureResult(c, user, lookupErr) + return + } + recordManageAuditFor(c, existing.Id, "gcwork.user.adopt", map[string]any{"id": existing.Id, "username": existing.Username, "provider": providers[0].Slug}) + common.ApiSuccess(c, gin.H{"id": existing.Id}) + return + } + } + } + candidates := []string{} if req.Username != "" && len(req.Username) <= model.UserNameMaxLength { candidates = append(candidates, req.Username) @@ -74,14 +137,25 @@ func GcworkEnsureUser(c *gin.Context) { Username: username, DisplayName: string(displayName[:min(len(displayName), 20)]), Email: email, - OidcId: req.ExternalID, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, } - insertErr = model.DB.Transaction(func(tx *gorm.DB) error { return user.InsertWithTx(tx, 0) }) + if len(providers) == 0 { + user.OidcId = req.ExternalID + } + insert := func(tx *gorm.DB) error { + if err := user.InsertWithTx(tx, 0); err != nil { + return err + } + if len(providers) == 0 { + return nil + } + return model.CreateUserOAuthBindingWithTx(tx, &model.UserOAuthBinding{UserId: user.Id, ProviderId: providers[0].Id, ProviderUserId: req.ExternalID}) + } + insertErr = model.DB.Transaction(insert) if errors.Is(insertErr, model.ErrEmailAlreadyTaken) { - user.Email, email = "", "" - insertErr = model.DB.Transaction(func(tx *gorm.DB) error { return user.InsertWithTx(tx, 0) }) + user.Id, user.Email, email = 0, "", "" + insertErr = model.DB.Transaction(insert) } if insertErr == nil { user.FinalizeOAuthUserCreation(0) @@ -89,16 +163,28 @@ func GcworkEnsureUser(c *gin.Context) { common.ApiSuccess(c, gin.H{"id": user.Id}) return } - // Another node may have created the user with this candidate meanwhile. - if existing, err := gcworkUserByExternalID(req.ExternalID); err != nil || existing != nil { + // Another node may have created the user meanwhile. + if existing, err := gcworkUserByExternalID(providers, req.ExternalID); err != nil || existing != nil { gcworkEnsureResult(c, existing, err) return } + user.Id = 0 } common.ApiError(c, insertErr) } -func gcworkUserByExternalID(externalID string) (*model.User, error) { +// gcworkUserByExternalID finds the user bound to a Casdoor subject through the +// configured providers' OAuth bindings, then through the OIDC id. +func gcworkUserByExternalID(providers []*model.CustomOAuthProvider, externalID string) (*model.User, error) { + for _, provider := range providers { + user, err := model.GetUserByOAuthBinding(provider.Id, externalID) + if err == nil { + return user, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } var user model.User err := model.DB.Where("oidc_id = ?", externalID).Order("id").First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/controller/gcwork_test.go b/controller/gcwork_test.go index c4b33ba4c080..a99ee635803f 100644 --- a/controller/gcwork_test.go +++ b/controller/gcwork_test.go @@ -156,3 +156,94 @@ func TestGcworkControlPlaneEndpoints(t *testing.T) { assert.True(t, call(writer, http.MethodDelete, path, "").Success) }) } + +func TestGcworkEnsureUserMatchesCasdoorBindings(t *testing.T) { + admin, _ := setupAccessTokenAudit(t) + require.NoError(t, model.DB.AutoMigrate(&model.CustomOAuthProvider{})) + sqlDB, err := model.DB.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + office := model.CustomOAuthProvider{Name: "Casdoor office", Slug: "casdoor-oa"} + research := model.CustomOAuthProvider{Name: "Casdoor research", Slug: "casdoor-rd"} + require.NoError(t, model.DB.Create(&office).Error) + require.NoError(t, model.DB.Create(&research).Error) + t.Setenv("GCWORK_OAUTH_PROVIDERS", "casdoor-oa, casdoor-rd") + router := newGcworkTestRouter() + writer, _ := createScopedAccessToken(t, admin.Id, time.Now().Add(time.Hour).Unix(), "user:write") + createUser := func(username string) model.User { + user := model.User{Username: username, DisplayName: username, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, AffCode: username} + require.NoError(t, model.DB.Create(&user).Error) + return user + } + bind := func(user model.User, provider model.CustomOAuthProvider, subject string) { + require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: provider.Id, ProviderUserId: subject}).Error) + } + ensure := func(subject, username string) (int, bool) { + response := accessTokenRequest(router, http.MethodPost, "/api/gcwork/users/ensure", writer, "", + fmt.Sprintf(`{"external_id":%q,"username":%q}`, subject, username)) + require.Equal(t, http.StatusOK, response.Code) + var payload tokenAPIResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload)) + var data struct { + ID int `json:"id"` + } + if payload.Success { + require.NoError(t, common.Unmarshal(payload.Data, &data)) + } + return data.ID, payload.Success + } + bindings := func(subject string) []model.UserOAuthBinding { + var found []model.UserOAuthBinding + require.NoError(t, model.DB.Where("provider_user_id = ?", subject).Find(&found).Error) + return found + } + + t.Run("returns the user bound through any configured provider", func(t *testing.T) { + user := createUser("A0001") + bind(user, research, "sub-1") + id, ok := ensure("sub-1", "A0001") + require.True(t, ok) + assert.Equal(t, user.Id, id) + }) + + t.Run("adopts an unbound user with the Casdoor username once", func(t *testing.T) { + user := createUser("A0002") + id, ok := ensure("sub-2", "A0002") + require.True(t, ok) + assert.Equal(t, user.Id, id) + require.Len(t, bindings("sub-2"), 1) + assert.Equal(t, office.Id, bindings("sub-2")[0].ProviderId) + again, ok := ensure("sub-2", "A0002") + require.True(t, ok) + assert.Equal(t, user.Id, again) + }) + + t.Run("does not adopt a user bound to another subject", func(t *testing.T) { + user := createUser("A0003") + bind(user, office, "someone-else") + id, ok := ensure("sub-3", "A0003") + require.True(t, ok) + assert.NotEqual(t, user.Id, id) + created, err := model.GetUserById(id, false) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(created.Username, "gcw_")) + assert.Empty(t, created.OidcId) + }) + + t.Run("creates a new user bound to the first provider", func(t *testing.T) { + id, ok := ensure("sub-4", "A0004") + require.True(t, ok) + created, err := model.GetUserById(id, false) + require.NoError(t, err) + assert.Equal(t, "A0004", created.Username) + require.Len(t, bindings("sub-4"), 1) + assert.Equal(t, id, bindings("sub-4")[0].UserId) + assert.Equal(t, office.Id, bindings("sub-4")[0].ProviderId) + }) + + t.Run("refuses an unknown provider slug", func(t *testing.T) { + t.Setenv("GCWORK_OAUTH_PROVIDERS", "missing") + _, ok := ensure("sub-5", "A0005") + assert.False(t, ok) + }) +}