From 416c36f3034e228a27258b5a8a15eec4e5e426ba Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Tue, 16 Jul 2024 13:33:16 -0500 Subject: [PATCH] allow synchronizing user status from OAuth2 login providers (#31572) This leverages the existing `sync_external_users` cron job to synchronize the `IsActive` flag on users who use an OAuth2 provider set to synchronize. This synchronization is done by checking for expired access tokens, and using the stored refresh token to request a new access token. If the response back from the OAuth2 provider is the `invalid_grant` error code, the user is marked as inactive. However, the user is able to reactivate their account by logging in the web browser through their OAuth2 flow. Also changed to support this is that a linked `ExternalLoginUser` is always created upon a login or signup via OAuth2. ### Notes on updating permissions Ideally, we would also refresh permissions from the configured OAuth provider (e.g., admin, restricted and group mappings) to match the implementation of LDAP. However, the OAuth library used for this `goth`, doesn't seem to support issuing a session via refresh tokens. The interface provides a [`RefreshToken` method](https://github.com/markbates/goth/blob/master/provider.go#L20), but the returned `oauth.Token` doesn't implement the `goth.Session` we would need to call `FetchUser`. Due to specific implementations, we would need to build a compatibility function for every provider, since they cast to concrete types (e.g. [Azure](https://github.com/markbates/goth/blob/master/providers/azureadv2/azureadv2.go#L132)) --------- Co-authored-by: Kyle D --- models/auth/source.go | 2 +- models/user/external_login_user.go | 41 ++++++- routers/web/auth/auth.go | 6 +- routers/web/auth/oauth.go | 65 +++++----- services/auth/source/oauth2/main_test.go | 14 +++ services/auth/source/oauth2/providers_test.go | 62 ++++++++++ services/auth/source/oauth2/source.go | 2 +- services/auth/source/oauth2/source_sync.go | 114 ++++++++++++++++++ .../auth/source/oauth2/source_sync_test.go | 100 +++++++++++++++ services/externalaccount/user.go | 6 +- templates/admin/auth/edit.tmpl | 2 +- templates/admin/auth/new.tmpl | 2 +- tests/integration/auth_ldap_test.go | 3 +- 13 files changed, 370 insertions(+), 49 deletions(-) create mode 100644 services/auth/source/oauth2/main_test.go create mode 100644 services/auth/source/oauth2/providers_test.go create mode 100644 services/auth/source/oauth2/source_sync.go create mode 100644 services/auth/source/oauth2/source_sync_test.go diff --git a/models/auth/source.go b/models/auth/source.go index f360ca9801..a3a250cd91 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -210,7 +210,7 @@ func CreateSource(ctx context.Context, source *Source) error { return ErrSourceAlreadyExist{source.Name} } // Synchronization is only available with LDAP for now - if !source.IsLDAP() { + if !source.IsLDAP() && !source.IsOAuth2() { source.IsSyncEnabled = false } diff --git a/models/user/external_login_user.go b/models/user/external_login_user.go index 965b7a5ed1..0e764efb9f 100644 --- a/models/user/external_login_user.go +++ b/models/user/external_login_user.go @@ -160,12 +160,34 @@ func UpdateExternalUserByExternalID(ctx context.Context, external *ExternalLogin return err } +// EnsureLinkExternalToUser link the external user to the user +func EnsureLinkExternalToUser(ctx context.Context, external *ExternalLoginUser) error { + has, err := db.Exist[ExternalLoginUser](ctx, builder.Eq{ + "external_id": external.ExternalID, + "login_source_id": external.LoginSourceID, + }) + if err != nil { + return err + } + + if has { + _, err = db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external) + return err + } + + _, err = db.GetEngine(ctx).Insert(external) + return err +} + // FindExternalUserOptions represents an options to find external users type FindExternalUserOptions struct { db.ListOptions - Provider string - UserID int64 - OrderBy string + Provider string + UserID int64 + LoginSourceID int64 + HasRefreshToken bool + Expired bool + OrderBy string } func (opts FindExternalUserOptions) ToConds() builder.Cond { @@ -176,9 +198,22 @@ func (opts FindExternalUserOptions) ToConds() builder.Cond { if opts.UserID > 0 { cond = cond.And(builder.Eq{"user_id": opts.UserID}) } + if opts.Expired { + cond = cond.And(builder.Lt{"expires_at": time.Now()}) + } + if opts.HasRefreshToken { + cond = cond.And(builder.Neq{"refresh_token": ""}) + } + if opts.LoginSourceID != 0 { + cond = cond.And(builder.Eq{"login_source_id": opts.LoginSourceID}) + } return cond } func (opts FindExternalUserOptions) ToOrders() string { return opts.OrderBy } + +func IterateExternalLogin(ctx context.Context, opts FindExternalUserOptions, f func(ctx context.Context, u *ExternalLoginUser) error) error { + return db.Iterate(ctx, opts.ToConds(), f) +} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 842020791f..15a80bc104 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -622,10 +622,8 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth. // update external user information if gothUser != nil { - if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { - if !errors.Is(err, util.ErrNotExist) { - log.Error("UpdateExternalUser failed: %v", err) - } + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil { + log.Error("EnsureLinkExternalToUser failed: %v", err) } } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 50f0dff2b6..1205c2c578 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -27,7 +27,6 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" auth_service "code.gitea.io/gitea/services/auth" @@ -1148,9 +1147,39 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model groups := getClaimedGroups(oauth2Source, &gothUser) + opts := &user_service.UpdateOptions{} + + // Reactivate user if they are deactivated + if !u.IsActive { + opts.IsActive = optional.Some(true) + } + + // Update GroupClaims + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { + ctx.ServerError("EnsureLinkExternalToUser", err) + return + } + // If this user is enrolled in 2FA and this source doesn't override it, // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. if !needs2FA { + // Register last login + opts.SetLastLogin = true + + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := updateSession(ctx, nil, map[string]any{ "uid": u.ID, "uname": u.Name, @@ -1162,29 +1191,6 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // Clear whatever CSRF cookie has right now, force to generate a new one ctx.Csrf.DeleteCookie(ctx) - opts := &user_service.UpdateOptions{ - SetLastLogin: true, - } - opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) - if err := user_service.UpdateUser(ctx, u, opts); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { - ctx.ServerError("SyncGroupsToTeams", err) - return - } - } - - // update external user information - if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { - if !errors.Is(err, util.ErrNotExist) { - log.Error("UpdateExternalUser failed: %v", err) - } - } - if err := resetLocale(ctx, u); err != nil { ctx.ServerError("resetLocale", err) return @@ -1200,22 +1206,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } - opts := &user_service.UpdateOptions{} - opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) - if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() { if err := user_service.UpdateUser(ctx, u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } } - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { - ctx.ServerError("SyncGroupsToTeams", err) - return - } - } - if err := updateSession(ctx, nil, map[string]any{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, diff --git a/services/auth/source/oauth2/main_test.go b/services/auth/source/oauth2/main_test.go new file mode 100644 index 0000000000..57c74fd3e7 --- /dev/null +++ b/services/auth/source/oauth2/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{}) +} diff --git a/services/auth/source/oauth2/providers_test.go b/services/auth/source/oauth2/providers_test.go new file mode 100644 index 0000000000..353816c71e --- /dev/null +++ b/services/auth/source/oauth2/providers_test.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +type fakeProvider struct{} + +func (p *fakeProvider) Name() string { + return "fake" +} + +func (p *fakeProvider) SetName(name string) {} + +func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) { + return nil, nil +} + +func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) { + return nil, nil +} + +func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) { + return goth.User{}, nil +} + +func (p *fakeProvider) Debug(bool) { +} + +func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + switch refreshToken { + case "expired": + return nil, &oauth2.RetrieveError{ + ErrorCode: "invalid_grant", + } + default: + return &oauth2.Token{ + AccessToken: "token", + TokenType: "Bearer", + RefreshToken: "refresh", + Expiry: time.Now().Add(time.Hour), + }, nil + } +} + +func (p *fakeProvider) RefreshTokenAvailable() bool { + return true +} + +func init() { + RegisterGothProvider( + NewSimpleProvider("fake", "Fake", []string{"account"}, + func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider { + return &fakeProvider{} + })) +} diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 675005e55a..3454c9ad55 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -36,7 +36,7 @@ func (source *Source) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &source) } -// ToDB exports an SMTPConfig to a serialized format. +// ToDB exports an OAuth2Config to a serialized format. func (source *Source) ToDB() ([]byte, error) { return json.Marshal(source) } diff --git a/services/auth/source/oauth2/source_sync.go b/services/auth/source/oauth2/source_sync.go new file mode 100644 index 0000000000..5e30313c8f --- /dev/null +++ b/services/auth/source/oauth2/source_sync.go @@ -0,0 +1,114 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Sync causes this OAuth2 source to synchronize its users with the db. +func (source *Source) Sync(ctx context.Context, updateExisting bool) error { + log.Trace("Doing: SyncExternalUsers[%s] %d", source.authSource.Name, source.authSource.ID) + + if !updateExisting { + log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.authSource.Name) + return nil + } + + provider, err := createProvider(source.authSource.Name, source) + if err != nil { + return err + } + + if !provider.RefreshTokenAvailable() { + log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.authSource.Name) + return nil + } + + opts := user_model.FindExternalUserOptions{ + HasRefreshToken: true, + Expired: true, + LoginSourceID: source.authSource.ID, + } + + return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error { + return source.refresh(ctx, provider, u) + }) +} + +func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error { + log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt) + + shouldDisable := false + + token, err := provider.RefreshToken(u.RefreshToken) + if err != nil { + if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" { + // this signals that the token is not valid and the user should be disabled + shouldDisable = true + } else { + return err + } + } + + user := &user_model.User{ + LoginName: u.ExternalID, + LoginType: auth.OAuth2, + LoginSource: u.LoginSourceID, + } + + hasUser, err := user_model.GetUser(ctx, user) + if err != nil { + return err + } + + // If the grant is no longer valid, disable the user and + // delete local tokens. If the OAuth2 provider still + // recognizes them as a valid user, they will be able to login + // via their provider and reactivate their account. + if shouldDisable { + log.Info("SyncExternalUsers[%s] disabling user %d", source.authSource.Name, user.ID) + + return db.WithTx(ctx, func(ctx context.Context) error { + if hasUser { + user.IsActive = false + err := user_model.UpdateUserCols(ctx, user, "is_active") + if err != nil { + return err + } + } + + // Delete stored tokens, since they are invalid. This + // also provents us from checking this in subsequent runs. + u.AccessToken = "" + u.RefreshToken = "" + u.ExpiresAt = time.Time{} + + return user_model.UpdateExternalUserByExternalID(ctx, u) + }) + } + + // Otherwise, update the tokens + u.AccessToken = token.AccessToken + u.ExpiresAt = token.Expiry + + // Some providers only update access tokens provide a new + // refresh token, so avoid updating it if it's empty + if token.RefreshToken != "" { + u.RefreshToken = token.RefreshToken + } + + err = user_model.UpdateExternalUserByExternalID(ctx, u) + + return err +} diff --git a/services/auth/source/oauth2/source_sync_test.go b/services/auth/source/oauth2/source_sync_test.go new file mode 100644 index 0000000000..e2f04bcb25 --- /dev/null +++ b/services/auth/source/oauth2/source_sync_test.go @@ -0,0 +1,100 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestSource(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + source := &Source{ + Provider: "fake", + authSource: &auth.Source{ + ID: 12, + Type: auth.OAuth2, + Name: "fake", + IsActive: true, + IsSyncEnabled: true, + }, + } + + user := &user_model.User{ + LoginName: "external", + LoginType: auth.OAuth2, + LoginSource: source.authSource.ID, + Name: "test", + Email: "external@example.com", + } + + err := user_model.CreateUser(context.Background(), user, &user_model.CreateUserOverwriteOptions{}) + assert.NoError(t, err) + + e := &user_model.ExternalLoginUser{ + ExternalID: "external", + UserID: user.ID, + LoginSourceID: user.LoginSource, + RefreshToken: "valid", + } + err = user_model.LinkExternalToUser(context.Background(), user, e) + assert.NoError(t, err) + + provider, err := createProvider(source.authSource.Name, source) + assert.NoError(t, err) + + t.Run("refresh", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + err := source.refresh(context.Background(), provider, e) + assert.NoError(t, err) + + e := &user_model.ExternalLoginUser{ + ExternalID: e.ExternalID, + LoginSourceID: e.LoginSourceID, + } + + ok, err := user_model.GetExternalLogin(context.Background(), e) + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, e.RefreshToken, "refresh") + assert.Equal(t, e.AccessToken, "token") + + u, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + assert.True(t, u.IsActive) + }) + + t.Run("expired", func(t *testing.T) { + err := source.refresh(context.Background(), provider, &user_model.ExternalLoginUser{ + ExternalID: "external", + UserID: user.ID, + LoginSourceID: user.LoginSource, + RefreshToken: "expired", + }) + assert.NoError(t, err) + + e := &user_model.ExternalLoginUser{ + ExternalID: e.ExternalID, + LoginSourceID: e.LoginSourceID, + } + + ok, err := user_model.GetExternalLogin(context.Background(), e) + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, e.RefreshToken, "") + assert.Equal(t, e.AccessToken, "") + + u, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + assert.False(t, u.IsActive) + }) + }) +} diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go index 3cfd8c81f9..b53e33654a 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -71,14 +71,14 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth return nil } -// UpdateExternalUser updates external user's information -func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { +// EnsureLinkExternalToUser link the gothUser to the user +func EnsureLinkExternalToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) if err != nil { return err } - return user_model.UpdateExternalUserByExternalID(ctx, externalLoginUser) + return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser) } // UpdateMigrationsByType updates all migrated repositories' posterid from gitServiceType to replace originalAuthorID to posterID diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index e140d6b5eb..660f0d0881 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -412,7 +412,7 @@

{{ctx.Locale.Tr "admin.auths.sspi_default_language_helper"}}

{{end}} - {{if .Source.IsLDAP}} + {{if (or .Source.IsLDAP .Source.IsOAuth2)}}
diff --git a/templates/admin/auth/new.tmpl b/templates/admin/auth/new.tmpl index f130e18f65..e3985cb227 100644 --- a/templates/admin/auth/new.tmpl +++ b/templates/admin/auth/new.tmpl @@ -59,7 +59,7 @@
-
+
diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go index 0d733f663a..317787f403 100644 --- a/tests/integration/auth_ldap_test.go +++ b/tests/integration/auth_ldap_test.go @@ -224,7 +224,8 @@ func TestLDAPUserSync(t *testing.T) { } defer tests.PrepareTestEnv(t)() addAuthSourceLDAP(t, "", "") - auth.SyncExternalUsers(context.Background(), true) + err := auth.SyncExternalUsers(context.Background(), true) + assert.NoError(t, err) // Check if users exists for _, gitLDAPUser := range gitLDAPUsers {