loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

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 <kdumontnu@gmail.com>
(cherry picked from commit 416c36f3034e228a27258b5a8a15eec4e5e426ba)

Conflicts:
- tests/integration/auth_ldap_test.go
Trivial conflict resolved by manually applying the change.
- routers/web/auth/oauth.go
Technically not a conflict, but the original PR removed the
modules/util import, which in our version, is still in use. Added it
back.

authored by

Rowan Bohde
Kyle D
and committed by
Gergely Nagy
21fdd28f 004cc6dc

+370 -48
+1 -1
models/auth/source.go
··· 216 216 return ErrSourceAlreadyExist{source.Name} 217 217 } 218 218 // Synchronization is only available with LDAP for now 219 - if !source.IsLDAP() { 219 + if !source.IsLDAP() && !source.IsOAuth2() { 220 220 source.IsSyncEnabled = false 221 221 } 222 222
+38 -3
models/user/external_login_user.go
··· 160 160 return err 161 161 } 162 162 163 + // EnsureLinkExternalToUser link the external user to the user 164 + func EnsureLinkExternalToUser(ctx context.Context, external *ExternalLoginUser) error { 165 + has, err := db.Exist[ExternalLoginUser](ctx, builder.Eq{ 166 + "external_id": external.ExternalID, 167 + "login_source_id": external.LoginSourceID, 168 + }) 169 + if err != nil { 170 + return err 171 + } 172 + 173 + if has { 174 + _, err = db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external) 175 + return err 176 + } 177 + 178 + _, err = db.GetEngine(ctx).Insert(external) 179 + return err 180 + } 181 + 163 182 // FindExternalUserOptions represents an options to find external users 164 183 type FindExternalUserOptions struct { 165 184 db.ListOptions 166 - Provider string 167 - UserID int64 168 - OrderBy string 185 + Provider string 186 + UserID int64 187 + LoginSourceID int64 188 + HasRefreshToken bool 189 + Expired bool 190 + OrderBy string 169 191 } 170 192 171 193 func (opts FindExternalUserOptions) ToConds() builder.Cond { ··· 176 198 if opts.UserID > 0 { 177 199 cond = cond.And(builder.Eq{"user_id": opts.UserID}) 178 200 } 201 + if opts.Expired { 202 + cond = cond.And(builder.Lt{"expires_at": time.Now()}) 203 + } 204 + if opts.HasRefreshToken { 205 + cond = cond.And(builder.Neq{"refresh_token": ""}) 206 + } 207 + if opts.LoginSourceID != 0 { 208 + cond = cond.And(builder.Eq{"login_source_id": opts.LoginSourceID}) 209 + } 179 210 return cond 180 211 } 181 212 182 213 func (opts FindExternalUserOptions) ToOrders() string { 183 214 return opts.OrderBy 184 215 } 216 + 217 + func IterateExternalLogin(ctx context.Context, opts FindExternalUserOptions, f func(ctx context.Context, u *ExternalLoginUser) error) error { 218 + return db.Iterate(ctx, opts.ToConds(), f) 219 + }
+2 -4
routers/web/auth/auth.go
··· 619 619 notify_service.NewUserSignUp(ctx, u) 620 620 // update external user information 621 621 if gothUser != nil { 622 - if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { 623 - if !errors.Is(err, util.ErrNotExist) { 624 - log.Error("UpdateExternalUser failed: %v", err) 625 - } 622 + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil { 623 + log.Error("EnsureLinkExternalToUser failed: %v", err) 626 624 } 627 625 } 628 626
+31 -33
routers/web/auth/oauth.go
··· 1154 1154 1155 1155 groups := getClaimedGroups(oauth2Source, &gothUser) 1156 1156 1157 + opts := &user_service.UpdateOptions{} 1158 + 1159 + // Reactivate user if they are deactivated 1160 + if !u.IsActive { 1161 + opts.IsActive = optional.Some(true) 1162 + } 1163 + 1164 + // Update GroupClaims 1165 + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) 1166 + 1167 + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { 1168 + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { 1169 + ctx.ServerError("SyncGroupsToTeams", err) 1170 + return 1171 + } 1172 + } 1173 + 1174 + if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { 1175 + ctx.ServerError("EnsureLinkExternalToUser", err) 1176 + return 1177 + } 1178 + 1157 1179 // If this user is enrolled in 2FA and this source doesn't override it, 1158 1180 // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. 1159 1181 if !needs2FA { 1182 + // Register last login 1183 + opts.SetLastLogin = true 1184 + 1185 + if err := user_service.UpdateUser(ctx, u, opts); err != nil { 1186 + ctx.ServerError("UpdateUser", err) 1187 + return 1188 + } 1189 + 1160 1190 if err := updateSession(ctx, nil, map[string]any{ 1161 1191 "uid": u.ID, 1162 1192 }); err != nil { ··· 1167 1197 // Clear whatever CSRF cookie has right now, force to generate a new one 1168 1198 ctx.Csrf.DeleteCookie(ctx) 1169 1199 1170 - opts := &user_service.UpdateOptions{ 1171 - SetLastLogin: true, 1172 - } 1173 - opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) 1174 - if err := user_service.UpdateUser(ctx, u, opts); err != nil { 1175 - ctx.ServerError("UpdateUser", err) 1176 - return 1177 - } 1178 - 1179 - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { 1180 - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { 1181 - ctx.ServerError("SyncGroupsToTeams", err) 1182 - return 1183 - } 1184 - } 1185 - 1186 - // update external user information 1187 - if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { 1188 - if !errors.Is(err, util.ErrNotExist) { 1189 - log.Error("UpdateExternalUser failed: %v", err) 1190 - } 1191 - } 1192 - 1193 1200 if err := resetLocale(ctx, u); err != nil { 1194 1201 ctx.ServerError("resetLocale", err) 1195 1202 return ··· 1205 1212 return 1206 1213 } 1207 1214 1208 - opts := &user_service.UpdateOptions{} 1209 - opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) 1210 - if opts.IsAdmin.Has() || opts.IsRestricted.Has() { 1215 + if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() { 1211 1216 if err := user_service.UpdateUser(ctx, u, opts); err != nil { 1212 1217 ctx.ServerError("UpdateUser", err) 1213 - return 1214 - } 1215 - } 1216 - 1217 - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { 1218 - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { 1219 - ctx.ServerError("SyncGroupsToTeams", err) 1220 1218 return 1221 1219 } 1222 1220 }
+14
services/auth/source/oauth2/main_test.go
··· 1 + // Copyright 2024 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package oauth2 5 + 6 + import ( 7 + "testing" 8 + 9 + "code.gitea.io/gitea/models/unittest" 10 + ) 11 + 12 + func TestMain(m *testing.M) { 13 + unittest.MainTest(m, &unittest.TestOptions{}) 14 + }
+62
services/auth/source/oauth2/providers_test.go
··· 1 + // Copyright 2024 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package oauth2 5 + 6 + import ( 7 + "time" 8 + 9 + "github.com/markbates/goth" 10 + "golang.org/x/oauth2" 11 + ) 12 + 13 + type fakeProvider struct{} 14 + 15 + func (p *fakeProvider) Name() string { 16 + return "fake" 17 + } 18 + 19 + func (p *fakeProvider) SetName(name string) {} 20 + 21 + func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) { 22 + return nil, nil 23 + } 24 + 25 + func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) { 26 + return nil, nil 27 + } 28 + 29 + func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) { 30 + return goth.User{}, nil 31 + } 32 + 33 + func (p *fakeProvider) Debug(bool) { 34 + } 35 + 36 + func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) { 37 + switch refreshToken { 38 + case "expired": 39 + return nil, &oauth2.RetrieveError{ 40 + ErrorCode: "invalid_grant", 41 + } 42 + default: 43 + return &oauth2.Token{ 44 + AccessToken: "token", 45 + TokenType: "Bearer", 46 + RefreshToken: "refresh", 47 + Expiry: time.Now().Add(time.Hour), 48 + }, nil 49 + } 50 + } 51 + 52 + func (p *fakeProvider) RefreshTokenAvailable() bool { 53 + return true 54 + } 55 + 56 + func init() { 57 + RegisterGothProvider( 58 + NewSimpleProvider("fake", "Fake", []string{"account"}, 59 + func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider { 60 + return &fakeProvider{} 61 + })) 62 + }
+1 -1
services/auth/source/oauth2/source.go
··· 36 36 return json.UnmarshalHandleDoubleEncode(bs, &source) 37 37 } 38 38 39 - // ToDB exports an SMTPConfig to a serialized format. 39 + // ToDB exports an OAuth2Config to a serialized format. 40 40 func (source *Source) ToDB() ([]byte, error) { 41 41 return json.Marshal(source) 42 42 }
+114
services/auth/source/oauth2/source_sync.go
··· 1 + // Copyright 2024 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package oauth2 5 + 6 + import ( 7 + "context" 8 + "time" 9 + 10 + "code.gitea.io/gitea/models/auth" 11 + "code.gitea.io/gitea/models/db" 12 + user_model "code.gitea.io/gitea/models/user" 13 + "code.gitea.io/gitea/modules/log" 14 + 15 + "github.com/markbates/goth" 16 + "golang.org/x/oauth2" 17 + ) 18 + 19 + // Sync causes this OAuth2 source to synchronize its users with the db. 20 + func (source *Source) Sync(ctx context.Context, updateExisting bool) error { 21 + log.Trace("Doing: SyncExternalUsers[%s] %d", source.authSource.Name, source.authSource.ID) 22 + 23 + if !updateExisting { 24 + log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.authSource.Name) 25 + return nil 26 + } 27 + 28 + provider, err := createProvider(source.authSource.Name, source) 29 + if err != nil { 30 + return err 31 + } 32 + 33 + if !provider.RefreshTokenAvailable() { 34 + log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.authSource.Name) 35 + return nil 36 + } 37 + 38 + opts := user_model.FindExternalUserOptions{ 39 + HasRefreshToken: true, 40 + Expired: true, 41 + LoginSourceID: source.authSource.ID, 42 + } 43 + 44 + return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error { 45 + return source.refresh(ctx, provider, u) 46 + }) 47 + } 48 + 49 + func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error { 50 + log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt) 51 + 52 + shouldDisable := false 53 + 54 + token, err := provider.RefreshToken(u.RefreshToken) 55 + if err != nil { 56 + if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" { 57 + // this signals that the token is not valid and the user should be disabled 58 + shouldDisable = true 59 + } else { 60 + return err 61 + } 62 + } 63 + 64 + user := &user_model.User{ 65 + LoginName: u.ExternalID, 66 + LoginType: auth.OAuth2, 67 + LoginSource: u.LoginSourceID, 68 + } 69 + 70 + hasUser, err := user_model.GetUser(ctx, user) 71 + if err != nil { 72 + return err 73 + } 74 + 75 + // If the grant is no longer valid, disable the user and 76 + // delete local tokens. If the OAuth2 provider still 77 + // recognizes them as a valid user, they will be able to login 78 + // via their provider and reactivate their account. 79 + if shouldDisable { 80 + log.Info("SyncExternalUsers[%s] disabling user %d", source.authSource.Name, user.ID) 81 + 82 + return db.WithTx(ctx, func(ctx context.Context) error { 83 + if hasUser { 84 + user.IsActive = false 85 + err := user_model.UpdateUserCols(ctx, user, "is_active") 86 + if err != nil { 87 + return err 88 + } 89 + } 90 + 91 + // Delete stored tokens, since they are invalid. This 92 + // also provents us from checking this in subsequent runs. 93 + u.AccessToken = "" 94 + u.RefreshToken = "" 95 + u.ExpiresAt = time.Time{} 96 + 97 + return user_model.UpdateExternalUserByExternalID(ctx, u) 98 + }) 99 + } 100 + 101 + // Otherwise, update the tokens 102 + u.AccessToken = token.AccessToken 103 + u.ExpiresAt = token.Expiry 104 + 105 + // Some providers only update access tokens provide a new 106 + // refresh token, so avoid updating it if it's empty 107 + if token.RefreshToken != "" { 108 + u.RefreshToken = token.RefreshToken 109 + } 110 + 111 + err = user_model.UpdateExternalUserByExternalID(ctx, u) 112 + 113 + return err 114 + }
+100
services/auth/source/oauth2/source_sync_test.go
··· 1 + // Copyright 2024 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package oauth2 5 + 6 + import ( 7 + "context" 8 + "testing" 9 + 10 + "code.gitea.io/gitea/models/auth" 11 + "code.gitea.io/gitea/models/unittest" 12 + user_model "code.gitea.io/gitea/models/user" 13 + 14 + "github.com/stretchr/testify/assert" 15 + ) 16 + 17 + func TestSource(t *testing.T) { 18 + assert.NoError(t, unittest.PrepareTestDatabase()) 19 + 20 + source := &Source{ 21 + Provider: "fake", 22 + authSource: &auth.Source{ 23 + ID: 12, 24 + Type: auth.OAuth2, 25 + Name: "fake", 26 + IsActive: true, 27 + IsSyncEnabled: true, 28 + }, 29 + } 30 + 31 + user := &user_model.User{ 32 + LoginName: "external", 33 + LoginType: auth.OAuth2, 34 + LoginSource: source.authSource.ID, 35 + Name: "test", 36 + Email: "external@example.com", 37 + } 38 + 39 + err := user_model.CreateUser(context.Background(), user, &user_model.CreateUserOverwriteOptions{}) 40 + assert.NoError(t, err) 41 + 42 + e := &user_model.ExternalLoginUser{ 43 + ExternalID: "external", 44 + UserID: user.ID, 45 + LoginSourceID: user.LoginSource, 46 + RefreshToken: "valid", 47 + } 48 + err = user_model.LinkExternalToUser(context.Background(), user, e) 49 + assert.NoError(t, err) 50 + 51 + provider, err := createProvider(source.authSource.Name, source) 52 + assert.NoError(t, err) 53 + 54 + t.Run("refresh", func(t *testing.T) { 55 + t.Run("valid", func(t *testing.T) { 56 + err := source.refresh(context.Background(), provider, e) 57 + assert.NoError(t, err) 58 + 59 + e := &user_model.ExternalLoginUser{ 60 + ExternalID: e.ExternalID, 61 + LoginSourceID: e.LoginSourceID, 62 + } 63 + 64 + ok, err := user_model.GetExternalLogin(context.Background(), e) 65 + assert.NoError(t, err) 66 + assert.True(t, ok) 67 + assert.Equal(t, e.RefreshToken, "refresh") 68 + assert.Equal(t, e.AccessToken, "token") 69 + 70 + u, err := user_model.GetUserByID(context.Background(), user.ID) 71 + assert.NoError(t, err) 72 + assert.True(t, u.IsActive) 73 + }) 74 + 75 + t.Run("expired", func(t *testing.T) { 76 + err := source.refresh(context.Background(), provider, &user_model.ExternalLoginUser{ 77 + ExternalID: "external", 78 + UserID: user.ID, 79 + LoginSourceID: user.LoginSource, 80 + RefreshToken: "expired", 81 + }) 82 + assert.NoError(t, err) 83 + 84 + e := &user_model.ExternalLoginUser{ 85 + ExternalID: e.ExternalID, 86 + LoginSourceID: e.LoginSourceID, 87 + } 88 + 89 + ok, err := user_model.GetExternalLogin(context.Background(), e) 90 + assert.NoError(t, err) 91 + assert.True(t, ok) 92 + assert.Equal(t, e.RefreshToken, "") 93 + assert.Equal(t, e.AccessToken, "") 94 + 95 + u, err := user_model.GetUserByID(context.Background(), user.ID) 96 + assert.NoError(t, err) 97 + assert.False(t, u.IsActive) 98 + }) 99 + }) 100 + }
+3 -3
services/externalaccount/user.go
··· 71 71 return nil 72 72 } 73 73 74 - // UpdateExternalUser updates external user's information 75 - func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { 74 + // EnsureLinkExternalToUser link the gothUser to the user 75 + func EnsureLinkExternalToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { 76 76 externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) 77 77 if err != nil { 78 78 return err 79 79 } 80 80 81 - return user_model.UpdateExternalUserByExternalID(ctx, externalLoginUser) 81 + return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser) 82 82 } 83 83 84 84 // UpdateMigrationsByType updates all migrated repositories' posterid from gitServiceType to replace originalAuthorID to posterID
+1 -1
templates/admin/auth/edit.tmpl
··· 416 416 <p class="help">{{ctx.Locale.Tr "admin.auths.sspi_default_language_helper"}}</p> 417 417 </div> 418 418 {{end}} 419 - {{if .Source.IsLDAP}} 419 + {{if (or .Source.IsLDAP .Source.IsOAuth2)}} 420 420 <div class="inline field"> 421 421 <div class="ui checkbox"> 422 422 <label><strong>{{ctx.Locale.Tr "admin.auths.syncenabled"}}</strong></label>
+1 -1
templates/admin/auth/new.tmpl
··· 59 59 <input name="attributes_in_bind" type="checkbox" {{if .attributes_in_bind}}checked{{end}}> 60 60 </div> 61 61 </div> 62 - <div class="ldap inline field {{if not (eq .type 2)}}tw-hidden{{end}}"> 62 + <div class="oauth2 ldap inline field {{if not (or (eq .type 2) (eq .type 6))}}tw-hidden{{end}}"> 63 63 <div class="ui checkbox"> 64 64 <label><strong>{{ctx.Locale.Tr "admin.auths.syncenabled"}}</strong></label> 65 65 <input name="is_sync_enabled" type="checkbox" {{if .is_sync_enabled}}checked{{end}}>
+2 -1
tests/integration/auth_ldap_test.go
··· 244 244 } 245 245 defer tests.PrepareTestEnv(t)() 246 246 addAuthSourceLDAP(t, "", "", "", "") 247 - auth.SyncExternalUsers(context.Background(), true) 247 + err := auth.SyncExternalUsers(context.Background(), true) 248 + assert.NoError(t, err) 248 249 249 250 // Check if users exists 250 251 for _, gitLDAPUser := range gitLDAPUsers {