Skip to content

allow synchronizing user status from OAuth2 login providers #31572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
allow synchronizing user status from OAuth2 login providers
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 OAuth flow.

Also changed to support this is that a linked `ExternalLoginUser` is
always created upon a login or signup via OAuth2.
  • Loading branch information
bohde committed Jul 5, 2024
commit b8411f448ef1427e44b0de4ed7f79b2b9c80c4fb
2 changes: 1 addition & 1 deletion models/auth/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
43 changes: 40 additions & 3 deletions models/user/external_login_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,36 @@ func UpdateExternalUserByExternalID(ctx context.Context, external *ExternalLogin
return err
}

// EnsureLinkExternalToUser link the external user to the user
func EnsureLinkExternalToUser(external *ExternalLoginUser) error {
has, err := db.Exist[ExternalLoginUser](db.DefaultContext, builder.Eq{
"external_id": external.ExternalID,
"login_source_id": external.LoginSourceID,
})
if err != nil {
return err
}

if has {
_, err = db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external)
return err
}

_, err = db.GetEngine(db.DefaultContext).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
Limit int
Start int
}

func (opts FindExternalUserOptions) ToConds() builder.Cond {
Expand All @@ -176,9 +200,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[ExternalLoginUser](ctx, opts.ToConds(), f)
}
6 changes: 2 additions & 4 deletions routers/web/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
65 changes: 31 additions & 34 deletions routers/web/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions services/auth/source/oauth2/main_test.go
Original file line number Diff line number Diff line change
@@ -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{})
}
62 changes: 62 additions & 0 deletions services/auth/source/oauth2/providers_test.go
Original file line number Diff line number Diff line change
@@ -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{}
}))
}
2 changes: 1 addition & 1 deletion services/auth/source/oauth2/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
114 changes: 114 additions & 0 deletions services/auth/source/oauth2/source_sync.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading