Merge remote-tracking branch 'upstream/forgejo' into feat/add-oidc-ssh-keys

This commit is contained in:
Maxim Slipenko 2024-12-19 10:45:59 +00:00
commit bbcd7d6fae
147 changed files with 2879 additions and 2901 deletions

View file

@ -1,14 +0,0 @@
// 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{})
}

View file

@ -1,62 +0,0 @@
// 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{}
}))
}

View file

@ -39,7 +39,7 @@ func (source *Source) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &source)
}
// ToDB exports an OAuth2Config to a serialized format.
// ToDB exports an SMTPConfig to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source)
}

View file

@ -1,209 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"context"
"fmt"
"time"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"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"
"code.gitea.io/gitea/modules/util"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/openidConnect"
"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
}
needUserFetch := source.ProvidesSSHKeys()
if needUserFetch {
fetchedUser, err := fetchUser(provider, token)
if err != nil {
log.Error("fetchUser: %v", err)
} else {
err = updateSSHKeys(ctx, source, user, &fetchedUser)
if err != nil {
log.Error("updateSshKeys: %v", err)
}
}
}
err = user_model.UpdateExternalUserByExternalID(ctx, u)
return err
}
func fetchUser(provider goth.Provider, token *oauth2.Token) (goth.User, error) {
state, err := util.CryptoRandomString(40)
if err != nil {
return goth.User{}, err
}
session, err := provider.BeginAuth(state)
if err != nil {
return goth.User{}, err
}
if s, ok := session.(*openidConnect.Session); ok {
s.AccessToken = token.AccessToken
s.RefreshToken = token.RefreshToken
s.ExpiresAt = token.Expiry
s.IDToken = token.Extra("id_token").(string)
}
gothUser, err := provider.FetchUser(session)
if err != nil {
return goth.User{}, err
}
return gothUser, nil
}
func updateSSHKeys(
ctx context.Context,
source *Source,
user *user_model.User,
fetchedUser *goth.User,
) error {
if source.ProvidesSSHKeys() {
sshKeys, err := getSSHKeys(source, fetchedUser)
if err != nil {
return err
}
if len(sshKeys) == 0 {
return nil
}
if asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sshKeys) {
err = asymkey_model.RewriteAllPublicKeys(ctx)
if err != nil {
return err
}
}
}
return nil
}
func getSSHKeys(source *Source, gothUser *goth.User) ([]string, error) {
key := source.AttributeSSHPublicKey
value, exists := gothUser.RawData[key]
if !exists {
return nil, fmt.Errorf("attribute '%s' not found in user data", key)
}
rawSlice, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("unexpected type for SSH public key, expected []interface{} but got %T", value)
}
sshKeys := make([]string, 0, len(rawSlice))
for i, v := range rawSlice {
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("unexpected element type at index %d in SSH public key array, expected string but got %T", i, v)
}
sshKeys = append(sshKeys, str)
}
return sshKeys, nil
}

View file

@ -1,101 +0,0 @@
// 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"
"github.com/stretchr/testify/require"
)
func TestSource(t *testing.T) {
require.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{})
require.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)
require.NoError(t, err)
provider, err := createProvider(source.authSource.Name, source)
require.NoError(t, err)
t.Run("refresh", func(t *testing.T) {
t.Run("valid", func(t *testing.T) {
err := source.refresh(context.Background(), provider, e)
require.NoError(t, err)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(context.Background(), e)
require.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "refresh", e.RefreshToken)
assert.Equal(t, "token", e.AccessToken)
u, err := user_model.GetUserByID(context.Background(), user.ID)
require.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",
})
require.NoError(t, err)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(context.Background(), e)
require.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)
require.NoError(t, err)
assert.False(t, u.IsActive)
})
})
}