初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
@@ -0,0 +1,21 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2_test
import (
auth_model "gitea.dev/models/auth"
"gitea.dev/services/auth"
"gitea.dev/services/auth/source/oauth2"
)
// This test file exists to assert that our Source exposes the interfaces that we expect
// It tightly binds the interfaces and implementation without breaking go import cycles
type sourceInterface interface {
auth_model.Config
auth_model.RegisterableSource
auth.PasswordAuthenticator
}
var _ (sourceInterface) = &oauth2.Source{}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"context"
"encoding/gob"
"net/http"
"sync"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"github.com/google/uuid"
"github.com/gorilla/sessions"
"github.com/markbates/goth/gothic"
)
var gothRWMutex = sync.RWMutex{}
// ProviderHeaderKey is the HTTP header key
const ProviderHeaderKey = "gitea-oauth2-provider"
// Init initializes the oauth source
func Init(ctx context.Context) error {
// Lock our mutex
gothRWMutex.Lock()
gob.Register(&sessions.Session{}) // TODO: CHI-SESSION-GOB-REGISTER. FIXME: it seems to be an abuse, why the Session struct itself is stored in session store again?
gothic.Store = &SessionsStore{
maxLength: int64(setting.OAuth2.MaxTokenLength),
}
gothic.SetState = func(req *http.Request) string {
return uuid.New().String()
}
gothic.GetProviderName = func(req *http.Request) (string, error) {
return req.Header.Get(ProviderHeaderKey), nil
}
// Unlock our mutex
gothRWMutex.Unlock()
return initOAuth2Sources(ctx)
}
// ResetOAuth2 clears existing OAuth2 providers and loads them from DB
func ResetOAuth2(ctx context.Context) error {
ClearProviders()
return initOAuth2Sources(ctx)
}
// initOAuth2Sources is used to load and register all active OAuth2 providers
func initOAuth2Sources(ctx context.Context) error {
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: optional.Some(true),
LoginType: auth.OAuth2,
})
if err != nil {
return err
}
for _, source := range authSources {
oauth2Source, ok := source.Cfg.(*Source)
if !ok {
continue
}
err := oauth2Source.RegisterSource()
if err != nil {
log.Error("Unable to register source: %s due to Error: %v.", source.Name, err)
}
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{})
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"context"
"errors"
"fmt"
"html"
"html/template"
"net/url"
"slices"
"sort"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/openidConnect"
)
// Provider is an interface for describing a single OAuth2 provider
type Provider interface {
Name() string
DisplayName() string
IconHTML(size int) template.HTML
CustomURLSettings() *CustomURLSettings
SupportSSHPublicKey() bool
}
// GothProviderCreator provides a function to create a goth.Provider
type GothProviderCreator interface {
CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error)
}
// GothProvider is an interface for describing a single OAuth2 provider
type GothProvider interface {
Provider
GothProviderCreator
}
// AuthSourceProvider provides a provider for an AuthSource. Multiple auth sources could use the same registered GothProvider
// So each auth source should have its own DisplayName and IconHTML for display.
// The Name is the GothProvider's name, to help to find the GothProvider to sign in.
// The DisplayName is the auth source config's name, site admin set it on the admin page, the IconURL can also be set there.
type AuthSourceProvider struct {
GothProvider
sourceName, iconURL string
}
func (p *AuthSourceProvider) Name() string {
return p.GothProvider.Name()
}
func (p *AuthSourceProvider) DisplayName() string {
return p.sourceName
}
func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
if p.iconURL != "" {
img := fmt.Sprintf(`<img class="tw-object-contain" width="%d" height="%d" src="%s" alt="%s">`,
size,
size,
html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),
)
return template.HTML(img)
}
return p.GothProvider.IconHTML(size)
}
// Providers contains the map of registered OAuth2 providers in Gitea (based on goth)
// key is used to map the OAuth2Provider with the goth provider type (also in AuthSource.OAuth2Config.Provider)
// value is used to store display data
var gothProviders = map[string]GothProvider{}
func isAzureProvider(name string) bool {
return name == "azuread" || name == "microsoftonline" || name == "azureadv2"
}
// RegisterGothProvider registers a GothProvider
func RegisterGothProvider(provider GothProvider) {
if _, has := gothProviders[provider.Name()]; has {
log.Fatal("Duplicate oauth2provider type provided: %s", provider.Name())
}
gothProviders[provider.Name()] = provider
}
// getExistingAzureADAuthSources returns a list of Azure AD provider names that are already configured
func getExistingAzureADAuthSources(ctx context.Context) ([]string, error) {
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
LoginType: auth.OAuth2,
})
if err != nil {
return nil, err
}
var existingAzureProviders []string
for _, source := range authSources {
if oauth2Cfg, ok := source.Cfg.(*Source); ok {
if isAzureProvider(oauth2Cfg.Provider) {
existingAzureProviders = append(existingAzureProviders, oauth2Cfg.Provider)
}
}
}
return existingAzureProviders, nil
}
// GetSupportedOAuth2Providers returns the list of supported OAuth2 providers with context for filtering
// key is used as technical name (like in the callbackURL)
// values to display
// Note: Azure AD providers (azuread, microsoftonline, azureadv2) are filtered out
// unless they already exist in the system to encourage use of OpenID Connect
func GetSupportedOAuth2Providers(ctx context.Context) []Provider {
providers := make([]Provider, 0, len(gothProviders))
existingAzureSources, err := getExistingAzureADAuthSources(ctx)
if err != nil {
log.Error("Failed to get existing OAuth2 auth sources: %v", err)
}
for _, provider := range gothProviders {
if isAzureProvider(provider.Name()) && !slices.Contains(existingAzureSources, provider.Name()) {
continue
}
providers = append(providers, provider)
}
sort.Slice(providers, func(i, j int) bool {
return providers[i].Name() < providers[j].Name()
})
return providers
}
func CreateProviderFromSource(source *auth.Source) (Provider, error) {
oauth2Cfg, ok := source.Cfg.(*Source)
if !ok {
return nil, fmt.Errorf("invalid OAuth2 source config: %v", oauth2Cfg)
}
gothProv := gothProviders[oauth2Cfg.Provider]
return &AuthSourceProvider{GothProvider: gothProv, sourceName: source.Name, iconURL: oauth2Cfg.IconURL}, nil
}
// GetOAuth2Providers returns the list of configured OAuth2 providers
func GetOAuth2Providers(ctx context.Context, isActive optional.Option[bool]) ([]Provider, error) {
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: isActive,
LoginType: auth.OAuth2,
})
if err != nil {
return nil, err
}
providers := make([]Provider, 0, len(authSources))
for _, source := range authSources {
provider, err := CreateProviderFromSource(source)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
sort.Slice(providers, func(i, j int) bool {
return providers[i].Name() < providers[j].Name()
})
return providers, nil
}
// RegisterProviderWithGothic register a OAuth2 provider in goth lib
func RegisterProviderWithGothic(providerName string, source *Source) error {
provider, err := createProvider(providerName, source)
if err == nil && provider != nil {
gothRWMutex.Lock()
defer gothRWMutex.Unlock()
goth.UseProviders(provider)
}
return err
}
// RemoveProviderFromGothic removes the given OAuth2 provider from the goth lib
func RemoveProviderFromGothic(providerName string) {
gothRWMutex.Lock()
defer gothRWMutex.Unlock()
delete(goth.GetProviders(), providerName)
}
// ClearProviders clears all OAuth2 providers from the goth lib
func ClearProviders() {
gothRWMutex.Lock()
defer gothRWMutex.Unlock()
goth.ClearProviders()
}
// GetOIDCEndSessionEndpoint returns the OIDC end_session_endpoint for the
// given provider name. Returns "" if the provider is not OIDC or doesn't
// advertise an end_session_endpoint in its discovery document.
func GetOIDCEndSessionEndpoint(providerName string) string {
gothRWMutex.RLock()
defer gothRWMutex.RUnlock()
provider, ok := goth.GetProviders()[providerName]
if !ok {
return ""
}
oidcProvider, ok := provider.(*openidConnect.Provider)
if !ok || oidcProvider.OpenIDConfig == nil {
return ""
}
return oidcProvider.OpenIDConfig.EndSessionEndpoint
}
var ErrAuthSourceNotActivated = errors.New("auth source is not activated")
// used to create different types of goth providers
func createProvider(providerName string, source *Source) (goth.Provider, error) {
callbackURL := setting.AppURL + "user/oauth2/" + url.PathEscape(providerName) + "/callback"
var provider goth.Provider
var err error
p, ok := gothProviders[source.Provider]
if !ok {
return nil, ErrAuthSourceNotActivated
}
provider, err = p.CreateGothProvider(providerName, callbackURL, source)
if err != nil {
return provider, err
}
// always set the name if provider is created so we can support multiple setups of 1 provider
if provider != nil {
provider.SetName(providerName)
}
return provider, err
}
@@ -0,0 +1,58 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"html/template"
"gitea.dev/modules/log"
"gitea.dev/modules/svg"
)
// BaseProvider represents a common base for Provider
type BaseProvider struct {
name string
displayName string
// TODO: maybe some providers also support SSH public keys, then they can set this to true
supportSSHPublicKey bool
}
func (b *BaseProvider) SupportSSHPublicKey() bool {
return b.supportSSHPublicKey
}
// Name provides the technical name for this provider
func (b *BaseProvider) Name() string {
return b.name
}
// DisplayName returns the friendly name for this provider
func (b *BaseProvider) DisplayName() string {
return b.displayName
}
// IconHTML returns icon HTML for this provider
func (b *BaseProvider) IconHTML(size int) template.HTML {
svgName := "gitea-" + b.name
switch b.name {
case "gplus":
svgName = "gitea-google"
case "github":
svgName = "octicon-mark-github"
}
svgHTML := svg.RenderHTML(svgName, size)
if svgHTML == "" {
log.Error("No SVG icon for oauth2 provider %q", b.name)
svgHTML = svg.RenderHTML("gitea-openid", size)
}
return svgHTML
}
// CustomURLSettings returns the custom url settings for this provider
func (b *BaseProvider) CustomURLSettings() *CustomURLSettings {
return nil
}
var _ Provider = &BaseProvider{}
@@ -0,0 +1,144 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"gitea.dev/modules/setting"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/azureadv2"
"github.com/markbates/goth/providers/gitea"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/gitlab"
"github.com/markbates/goth/providers/mastodon"
"github.com/markbates/goth/providers/nextcloud"
)
// CustomProviderNewFn creates a goth.Provider using a custom url mapping
type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error)
// CustomProvider is a GothProvider that has CustomURL features
type CustomProvider struct {
BaseProvider
customURLSettings *CustomURLSettings
newFn CustomProviderNewFn
}
// CustomURLSettings returns the CustomURLSettings for this provider
func (c *CustomProvider) CustomURLSettings() *CustomURLSettings {
return c.customURLSettings
}
// CreateGothProvider creates a GothProvider from this Provider
func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
custom := c.customURLSettings.OverrideWith(source.CustomURLMapping)
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom, source.Scopes)
}
// NewCustomProvider is a constructor function for custom providers
func NewCustomProvider(name, displayName string, customURLSetting *CustomURLSettings, newFn CustomProviderNewFn) *CustomProvider {
return &CustomProvider{
BaseProvider: BaseProvider{
name: name,
displayName: displayName,
},
customURLSettings: customURLSetting,
newFn: newFn,
}
}
var _ GothProvider = &CustomProvider{}
func init() {
RegisterGothProvider(NewCustomProvider(
"github", "GitHub", &CustomURLSettings{
TokenURL: availableAttribute(github.TokenURL),
AuthURL: availableAttribute(github.AuthURL),
ProfileURL: availableAttribute(github.ProfileURL),
EmailURL: availableAttribute(github.EmailURL),
},
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
if setting.OAuth2Client.EnableAutoRegistration {
scopes = append(scopes, "user:email")
}
return github.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, custom.EmailURL, scopes...), nil
}))
RegisterGothProvider(NewCustomProvider(
"gitlab", "GitLab", &CustomURLSettings{
AuthURL: availableAttribute(gitlab.AuthURL),
TokenURL: availableAttribute(gitlab.TokenURL),
ProfileURL: availableAttribute(gitlab.ProfileURL),
}, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
scopes = append(scopes, "read_user")
return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
}))
RegisterGothProvider(NewCustomProvider(
"gitea", "Gitea", &CustomURLSettings{
TokenURL: requiredAttribute(gitea.TokenURL),
AuthURL: requiredAttribute(gitea.AuthURL),
ProfileURL: requiredAttribute(gitea.ProfileURL),
},
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
}))
RegisterGothProvider(NewCustomProvider(
"nextcloud", "Nextcloud", &CustomURLSettings{
TokenURL: requiredAttribute(nextcloud.TokenURL),
AuthURL: requiredAttribute(nextcloud.AuthURL),
ProfileURL: requiredAttribute(nextcloud.ProfileURL),
},
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
}))
RegisterGothProvider(NewCustomProvider(
"mastodon", "Mastodon", &CustomURLSettings{
AuthURL: requiredAttribute(mastodon.InstanceURL),
},
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil
}))
RegisterGothProvider(NewCustomProvider(
"azureadv2", "Azure AD v2", &CustomURLSettings{
Tenant: requiredAttribute("organizations"),
},
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
azureScopes := make([]azureadv2.ScopeType, len(scopes))
for i, scope := range scopes {
azureScopes[i] = azureadv2.ScopeType(scope)
}
return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{
Tenant: azureadv2.TenantType(custom.Tenant),
Scopes: azureScopes,
}), nil
},
))
RegisterGothProvider(&AwsCognitoProvider{})
}
const ProviderNameAwsCognito = "aws-cognito"
// AwsCognitoProvider is a GothProvider for AWS Cognito (based on OpenID Connect)
type AwsCognitoProvider struct {
OpenIDProvider
}
// Name provides the technical name for this provider
func (c *AwsCognitoProvider) Name() string {
return ProviderNameAwsCognito
}
// DisplayName returns the friendly name for this provider
func (c *AwsCognitoProvider) DisplayName() string {
return "AWS Cognito"
}
var _ GothProvider = &AwsCognitoProvider{}
@@ -0,0 +1,68 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"html/template"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/svg"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/openidConnect"
)
// OpenIDProvider is a GothProvider for OpenID
type OpenIDProvider struct{}
func (o *OpenIDProvider) SupportSSHPublicKey() bool {
return true
}
// Name provides the technical name for this provider
func (o *OpenIDProvider) Name() string {
return "openidConnect"
}
// DisplayName returns the friendly name for this provider
func (o *OpenIDProvider) DisplayName() string {
return "OpenID Connect"
}
// IconHTML returns icon HTML for this provider
func (o *OpenIDProvider) IconHTML(size int) template.HTML {
return svg.RenderHTML("gitea-openid", size)
}
// CreateGothProvider creates a GothProvider from this Provider
func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
scopes := setting.OAuth2Client.OpenIDConnectScopes
if len(scopes) == 0 {
scopes = append(scopes, source.Scopes...)
}
provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
if err != nil {
log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
return nil, err
}
if source.ExternalIDClaim != "" {
// UserIdClaims is a fallback list; goth returns the first non-empty matching claim.
// A single entry is sufficient because the admin explicitly chooses one claim (e.g. "oid" for Azure AD).
provider.UserIdClaims = []string{source.ExternalIDClaim}
}
return provider, nil
}
// CustomURLSettings returns the custom url settings for this provider
func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
return nil
}
var _ GothProvider = &OpenIDProvider{}
func init() {
RegisterGothProvider(&OpenIDProvider{})
}
@@ -0,0 +1,109 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"gitea.dev/modules/setting"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/azuread"
"github.com/markbates/goth/providers/bitbucket"
"github.com/markbates/goth/providers/discord"
"github.com/markbates/goth/providers/dropbox"
"github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/google"
"github.com/markbates/goth/providers/microsoftonline"
"github.com/markbates/goth/providers/twitter"
"github.com/markbates/goth/providers/yandex"
)
// SimpleProviderNewFn create goth.Providers without custom url features
type SimpleProviderNewFn func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider
// SimpleProvider is a GothProvider which does not have custom url features
type SimpleProvider struct {
BaseProvider
scopes []string
newFn SimpleProviderNewFn
}
// CreateGothProvider creates a GothProvider from this Provider
func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
scopes := make([]string, len(c.scopes)+len(source.Scopes))
copy(scopes, c.scopes)
copy(scopes[len(c.scopes):], source.Scopes)
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, scopes...), nil
}
// NewSimpleProvider is a constructor function for simple providers
func NewSimpleProvider(name, displayName string, scopes []string, newFn SimpleProviderNewFn) *SimpleProvider {
return &SimpleProvider{
BaseProvider: BaseProvider{
name: name,
displayName: displayName,
},
scopes: scopes,
newFn: newFn,
}
}
var _ GothProvider = &SimpleProvider{}
func init() {
RegisterGothProvider(
NewSimpleProvider("bitbucket", "Bitbucket", []string{"account"},
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
return bitbucket.New(clientKey, secret, callbackURL, scopes...)
}))
RegisterGothProvider(
NewSimpleProvider("dropbox", "Dropbox", nil,
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
return dropbox.New(clientKey, secret, callbackURL, scopes...)
}))
RegisterGothProvider(NewSimpleProvider("facebook", "Facebook", nil,
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
return facebook.New(clientKey, secret, callbackURL, scopes...)
}))
// named gplus due to legacy gplus -> google migration (Google killed Google+). This ensures old connections still work
RegisterGothProvider(NewSimpleProvider("gplus", "Google", []string{"email"},
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
if setting.OAuth2Client.UpdateAvatar || setting.OAuth2Client.EnableAutoRegistration {
scopes = append(scopes, "profile")
}
return google.New(clientKey, secret, callbackURL, scopes...)
}))
RegisterGothProvider(NewSimpleProvider("twitter", "Twitter", nil,
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
return twitter.New(clientKey, secret, callbackURL)
}))
RegisterGothProvider(NewSimpleProvider("discord", "Discord", []string{discord.ScopeIdentify, discord.ScopeEmail},
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
return discord.New(clientKey, secret, callbackURL, scopes...)
}))
// See https://tech.yandex.com/passport/doc/dg/reference/response-docpage/
RegisterGothProvider(NewSimpleProvider("yandex", "Yandex", []string{"login:email", "login:info", "login:avatar"},
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
return yandex.New(clientKey, secret, callbackURL, scopes...)
}))
RegisterGothProvider(NewSimpleProvider(
"azuread", "Azure AD", nil,
func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
return azuread.New(clientID, secret, callbackURL, nil, scopes...)
},
))
RegisterGothProvider(NewSimpleProvider(
"microsoftonline", "Microsoft Online", nil,
func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
return microsoftonline.New(clientID, secret, callbackURL, scopes...)
},
))
}
@@ -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 //nolint:nilnil // the auth method is not applicable
}
func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
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{}
}))
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"gitea.dev/models/auth"
"gitea.dev/modules/json"
)
// Source holds configuration for the OAuth2 login source.
type Source struct {
auth.ConfigBase `json:"-"`
Provider string
ClientID string
ClientSecret string
OpenIDConnectAutoDiscoveryURL string
CustomURLMapping *CustomURLMapping
IconURL string
Scopes []string
RequiredClaimName string
RequiredClaimValue string
GroupClaimName string
AdminGroup string
GroupTeamMap string
GroupTeamMapRemoval bool
RestrictedGroup string
SSHPublicKeyClaimName string
FullNameClaimName string
ExternalIDClaim string
}
// FromDB fills up an OAuth2Config from serialized format.
func (source *Source) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &source)
}
// ToDB exports an OAuth2Config to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source)
}
func init() {
auth.RegisterTypeConfig(auth.OAuth2, &Source{})
}
@@ -0,0 +1,19 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"context"
user_model "gitea.dev/models/user"
"gitea.dev/services/auth/source/db"
)
// Authenticate falls back to the db authenticator
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
return db.Authenticate(ctx, user, login, password)
}
// NB: Oauth2 does not implement LocalTwoFASkipper for password authentication
// as its password authentication drops to db authentication
@@ -0,0 +1,47 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"net/http"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
)
// Callout redirects request/response pair to authenticate against the provider
func (source *Source) Callout(request *http.Request, response http.ResponseWriter) error {
// not sure if goth is thread safe (?) when using multiple providers
request.Header.Set(ProviderHeaderKey, source.AuthSource.Name)
// don't use the default gothic begin handler to prevent issues when some error occurs
// normally the gothic library will write some custom stuff to the response instead of our own nice error page
// gothic.BeginAuthHandler(response, request)
gothRWMutex.RLock()
defer gothRWMutex.RUnlock()
url, err := gothic.GetAuthURL(response, request)
if err == nil {
http.Redirect(response, request, url, http.StatusTemporaryRedirect)
}
return err
}
// Callback handles OAuth callback, resolve to a goth user and send back to original url
// this will trigger a new authentication request, but because we save it in the session we can use that
func (source *Source) Callback(request *http.Request, response http.ResponseWriter) (goth.User, error) {
// not sure if goth is thread safe (?) when using multiple providers
request.Header.Set(ProviderHeaderKey, source.AuthSource.Name)
gothRWMutex.RLock()
defer gothRWMutex.RUnlock()
user, err := gothic.CompleteUserAuth(response, request)
if err != nil {
return user, err
}
return user, nil
}
@@ -0,0 +1,18 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
// Name returns the provider name of this source
func (source *Source) Name() string {
return source.Provider
}
// DisplayName returns the display name of this source
func (source *Source) DisplayName() string {
provider, has := gothProviders[source.Provider]
if !has {
return source.Provider
}
return provider.DisplayName()
}
@@ -0,0 +1,50 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"fmt"
)
// RegisterSource causes an OAuth2 configuration to be registered
func (source *Source) RegisterSource() error {
err := RegisterProviderWithGothic(source.AuthSource.Name, source)
return wrapOpenIDConnectInitializeError(err, source.AuthSource.Name, source)
}
// UnregisterSource causes an OAuth2 configuration to be unregistered
func (source *Source) UnregisterSource() error {
RemoveProviderFromGothic(source.AuthSource.Name)
return nil
}
// ErrOpenIDConnectInitialize represents a "OpenIDConnectInitialize" kind of error.
type ErrOpenIDConnectInitialize struct {
OpenIDConnectAutoDiscoveryURL string
ProviderName string
Cause error
}
// IsErrOpenIDConnectInitialize checks if an error is a ExternalLoginUserAlreadyExist.
func IsErrOpenIDConnectInitialize(err error) bool {
_, ok := err.(ErrOpenIDConnectInitialize)
return ok
}
func (err ErrOpenIDConnectInitialize) Error() string {
return fmt.Sprintf("Failed to initialize OpenID Connect Provider with name '%s' with url '%s': %v", err.ProviderName, err.OpenIDConnectAutoDiscoveryURL, err.Cause)
}
func (err ErrOpenIDConnectInitialize) Unwrap() error {
return err.Cause
}
// wrapOpenIDConnectInitializeError is used to wrap the error but this cannot be done in modules/auth/oauth2
// inside oauth2: import cycle not allowed models -> modules/auth/oauth2 -> models
func wrapOpenIDConnectInitializeError(err error, providerName string, source *Source) error {
if err != nil && source.Provider == "openidConnect" {
err = ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: source.OpenIDConnectAutoDiscoveryURL, Cause: err}
}
return err
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"context"
"time"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/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.GetIndividualUser(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
}
@@ -0,0 +1,101 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"testing"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestSource(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
source := &Source{
Provider: "fake",
ConfigBase: auth.ConfigBase{
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(t.Context(), user, &user_model.Meta{}, &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(t.Context(), 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(t.Context(), provider, e)
assert.NoError(t, err)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
assert.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "refresh", e.RefreshToken)
assert.Equal(t, "token", e.AccessToken)
u, err := user_model.GetUserByID(t.Context(), user.ID)
assert.NoError(t, err)
assert.True(t, u.IsActive)
})
t.Run("expired", func(t *testing.T) {
err := source.refresh(t.Context(), 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(t.Context(), e)
assert.NoError(t, err)
assert.True(t, ok)
assert.Empty(t, e.RefreshToken)
assert.Empty(t, e.AccessToken)
u, err := user_model.GetUserByID(t.Context(), user.ID)
assert.NoError(t, err)
assert.False(t, u.IsActive)
})
})
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
import (
"encoding/gob"
"fmt"
"net/http"
"gitea.dev/modules/log"
session_module "gitea.dev/modules/session"
"github.com/gorilla/sessions"
)
// SessionsStore creates a gothic store from our session
type SessionsStore struct {
maxLength int64
}
// Get should return a cached session.
func (st *SessionsStore) Get(r *http.Request, name string) (*sessions.Session, error) {
return st.getOrNew(r, name, false)
}
// New should create and return a new session.
//
// Note that New should never return a nil session, even in the case of
// an error if using the Registry infrastructure to cache the session.
func (st *SessionsStore) New(r *http.Request, name string) (*sessions.Session, error) {
return st.getOrNew(r, name, true)
}
// getOrNew gets the session from the chi-session if it exists. Override permits the overriding of an unexpected object.
func (st *SessionsStore) getOrNew(r *http.Request, name string, override bool) (*sessions.Session, error) {
store := session_module.GetContextSession(r)
session := sessions.NewSession(st, name)
rawData := store.Get(name)
if rawData != nil {
oldSession, ok := rawData.(*sessions.Session)
if ok {
session.ID = oldSession.ID
session.IsNew = oldSession.IsNew
session.Options = oldSession.Options
session.Values = oldSession.Values
return session, nil
} else if !override {
log.Error("Unexpected object in session at name: %s: %v", name, rawData)
return nil, fmt.Errorf("unexpected object in session at name: %s", name)
}
}
session.IsNew = override
session.ID = store.ID() // Simply copy the session id from the chi store
return session, store.Set(name, session)
}
// Save should persist session to the underlying store implementation.
func (st *SessionsStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
store := session_module.GetContextSession(r)
if session.IsNew {
_, _ = session_module.RegenerateSession(w, r)
session.IsNew = false
}
if err := store.Set(session.Name(), session); err != nil {
return err
}
if st.maxLength > 0 {
sizeWriter := &sizeWriter{}
_ = gob.NewEncoder(sizeWriter).Encode(session)
if sizeWriter.size > st.maxLength {
return fmt.Errorf("encode session: Data too long: %d > %d", sizeWriter.size, st.maxLength)
}
}
return store.Release()
}
type sizeWriter struct {
size int64
}
func (s *sizeWriter) Write(data []byte) (int, error) {
s.size += int64(len(data))
return len(data), nil
}
var _ (sessions.Store) = &SessionsStore{}
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
// CustomURLMapping describes the urls values to use when customizing OAuth2 provider URLs
type CustomURLMapping struct {
AuthURL string `json:",omitempty"`
TokenURL string `json:",omitempty"`
ProfileURL string `json:",omitempty"`
EmailURL string `json:",omitempty"`
Tenant string `json:",omitempty"`
}
// CustomURLSettings describes the urls values and availability to use when customizing OAuth2 provider URLs
type CustomURLSettings struct {
AuthURL Attribute
TokenURL Attribute
ProfileURL Attribute
EmailURL Attribute
Tenant Attribute
}
// Attribute describes the availability, and required status for a custom url configuration
type Attribute struct {
Value string
Available bool
Required bool
}
func availableAttribute(value string) Attribute {
return Attribute{Value: value, Available: true}
}
func requiredAttribute(value string) Attribute {
return Attribute{Value: value, Available: true, Required: true}
}
// Required is true if any attribute is required
func (c *CustomURLSettings) Required() bool {
if c == nil {
return false
}
if c.AuthURL.Required || c.EmailURL.Required || c.ProfileURL.Required || c.TokenURL.Required || c.Tenant.Required {
return true
}
return false
}
// OverrideWith copies the current customURLMapping and overrides it with values from the provided mapping
func (c *CustomURLSettings) OverrideWith(override *CustomURLMapping) *CustomURLMapping {
custom := &CustomURLMapping{
AuthURL: c.AuthURL.Value,
TokenURL: c.TokenURL.Value,
ProfileURL: c.ProfileURL.Value,
EmailURL: c.EmailURL.Value,
Tenant: c.Tenant.Value,
}
if override != nil {
if len(override.AuthURL) > 0 && c.AuthURL.Available {
custom.AuthURL = override.AuthURL
}
if len(override.TokenURL) > 0 && c.TokenURL.Available {
custom.TokenURL = override.TokenURL
}
if len(override.ProfileURL) > 0 && c.ProfileURL.Available {
custom.ProfileURL = override.ProfileURL
}
if len(override.EmailURL) > 0 && c.EmailURL.Available {
custom.EmailURL = override.EmailURL
}
if len(override.Tenant) > 0 && c.Tenant.Available {
custom.Tenant = override.Tenant
}
}
return custom
}