Skip to content
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
41 changes: 38 additions & 3 deletions models/user/external_login_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
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