diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
commit | dd136858f1ea40ad3c94191d647487fa4f31926c (patch) | |
tree | 58fec94a7b2a12510c9664b21793f1ed560c6518 /services/auth/source/oauth2 | |
parent | Initial commit. (diff) | |
download | forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip |
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'services/auth/source/oauth2')
20 files changed, 1815 insertions, 0 deletions
diff --git a/services/auth/source/oauth2/assert_interface_test.go b/services/auth/source/oauth2/assert_interface_test.go new file mode 100644 index 0000000..56fe0e4 --- /dev/null +++ b/services/auth/source/oauth2/assert_interface_test.go @@ -0,0 +1,22 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2_test + +import ( + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/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.SourceSettable + auth_model.RegisterableSource + auth.PasswordAuthenticator +} + +var _ (sourceInterface) = &oauth2.Source{} diff --git a/services/auth/source/oauth2/init.go b/services/auth/source/oauth2/init.go new file mode 100644 index 0000000..5c25681 --- /dev/null +++ b/services/auth/source/oauth2/init.go @@ -0,0 +1,86 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "encoding/gob" + "net/http" + "sync" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + + "github.com/google/uuid" + "github.com/gorilla/sessions" + "github.com/markbates/goth/gothic" +) + +var gothRWMutex = sync.RWMutex{} + +// UsersStoreKey is the key for the store +const UsersStoreKey = "gitea-oauth2-sessions" + +// ProviderHeaderKey is the HTTP header key +const ProviderHeaderKey = "gitea-oauth2-provider" + +// Init initializes the oauth source +func Init(ctx context.Context) error { + if err := InitSigningKey(); err != nil { + return err + } + + // Lock our mutex + gothRWMutex.Lock() + + gob.Register(&sessions.Session{}) + + 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.Critical("Unable to register source: %s due to Error: %v.", source.Name, err) + } + } + return nil +} diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go new file mode 100644 index 0000000..070fffe --- /dev/null +++ b/services/auth/source/oauth2/jwtsigningkey.go @@ -0,0 +1,404 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "github.com/golang-jwt/jwt/v5" +) + +// ErrInvalidAlgorithmType represents an invalid algorithm error. +type ErrInvalidAlgorithmType struct { + Algorithm string +} + +func (err ErrInvalidAlgorithmType) Error() string { + return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm) +} + +// JWTSigningKey represents a algorithm/key pair to sign JWTs +type JWTSigningKey interface { + IsSymmetric() bool + SigningMethod() jwt.SigningMethod + SignKey() any + VerifyKey() any + ToJWK() (map[string]string, error) + PreProcessToken(*jwt.Token) +} + +type hmacSigningKey struct { + signingMethod jwt.SigningMethod + secret []byte +} + +func (key hmacSigningKey) IsSymmetric() bool { + return true +} + +func (key hmacSigningKey) SigningMethod() jwt.SigningMethod { + return key.signingMethod +} + +func (key hmacSigningKey) SignKey() any { + return key.secret +} + +func (key hmacSigningKey) VerifyKey() any { + return key.secret +} + +func (key hmacSigningKey) ToJWK() (map[string]string, error) { + return map[string]string{ + "kty": "oct", + "alg": key.SigningMethod().Alg(), + }, nil +} + +func (key hmacSigningKey) PreProcessToken(*jwt.Token) {} + +type rsaSingingKey struct { + signingMethod jwt.SigningMethod + key *rsa.PrivateKey + id string +} + +func newRSASingingKey(signingMethod jwt.SigningMethod, key *rsa.PrivateKey) (rsaSingingKey, error) { + kid, err := util.CreatePublicKeyFingerprint(key.Public().(*rsa.PublicKey)) + if err != nil { + return rsaSingingKey{}, err + } + + return rsaSingingKey{ + signingMethod, + key, + base64.RawURLEncoding.EncodeToString(kid), + }, nil +} + +func (key rsaSingingKey) IsSymmetric() bool { + return false +} + +func (key rsaSingingKey) SigningMethod() jwt.SigningMethod { + return key.signingMethod +} + +func (key rsaSingingKey) SignKey() any { + return key.key +} + +func (key rsaSingingKey) VerifyKey() any { + return key.key.Public() +} + +func (key rsaSingingKey) ToJWK() (map[string]string, error) { + pubKey := key.key.Public().(*rsa.PublicKey) + + return map[string]string{ + "kty": "RSA", + "alg": key.SigningMethod().Alg(), + "kid": key.id, + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pubKey.E)).Bytes()), + "n": base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()), + }, nil +} + +func (key rsaSingingKey) PreProcessToken(token *jwt.Token) { + token.Header["kid"] = key.id +} + +type eddsaSigningKey struct { + signingMethod jwt.SigningMethod + key ed25519.PrivateKey + id string +} + +func newEdDSASingingKey(signingMethod jwt.SigningMethod, key ed25519.PrivateKey) (eddsaSigningKey, error) { + kid, err := util.CreatePublicKeyFingerprint(key.Public().(ed25519.PublicKey)) + if err != nil { + return eddsaSigningKey{}, err + } + + return eddsaSigningKey{ + signingMethod, + key, + base64.RawURLEncoding.EncodeToString(kid), + }, nil +} + +func (key eddsaSigningKey) IsSymmetric() bool { + return false +} + +func (key eddsaSigningKey) SigningMethod() jwt.SigningMethod { + return key.signingMethod +} + +func (key eddsaSigningKey) SignKey() any { + return key.key +} + +func (key eddsaSigningKey) VerifyKey() any { + return key.key.Public() +} + +func (key eddsaSigningKey) ToJWK() (map[string]string, error) { + pubKey := key.key.Public().(ed25519.PublicKey) + + return map[string]string{ + "alg": key.SigningMethod().Alg(), + "kid": key.id, + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + }, nil +} + +func (key eddsaSigningKey) PreProcessToken(token *jwt.Token) { + token.Header["kid"] = key.id +} + +type ecdsaSingingKey struct { + signingMethod jwt.SigningMethod + key *ecdsa.PrivateKey + id string +} + +func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) { + kid, err := util.CreatePublicKeyFingerprint(key.Public().(*ecdsa.PublicKey)) + if err != nil { + return ecdsaSingingKey{}, err + } + + return ecdsaSingingKey{ + signingMethod, + key, + base64.RawURLEncoding.EncodeToString(kid), + }, nil +} + +func (key ecdsaSingingKey) IsSymmetric() bool { + return false +} + +func (key ecdsaSingingKey) SigningMethod() jwt.SigningMethod { + return key.signingMethod +} + +func (key ecdsaSingingKey) SignKey() any { + return key.key +} + +func (key ecdsaSingingKey) VerifyKey() any { + return key.key.Public() +} + +func (key ecdsaSingingKey) ToJWK() (map[string]string, error) { + pubKey := key.key.Public().(*ecdsa.PublicKey) + + return map[string]string{ + "kty": "EC", + "alg": key.SigningMethod().Alg(), + "kid": key.id, + "crv": pubKey.Params().Name, + "x": base64.RawURLEncoding.EncodeToString(pubKey.X.Bytes()), + "y": base64.RawURLEncoding.EncodeToString(pubKey.Y.Bytes()), + }, nil +} + +func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) { + token.Header["kid"] = key.id +} + +// CreateJWTSigningKey creates a signing key from an algorithm / key pair. +func CreateJWTSigningKey(algorithm string, key any) (JWTSigningKey, error) { + var signingMethod jwt.SigningMethod + switch algorithm { + case "HS256": + signingMethod = jwt.SigningMethodHS256 + case "HS384": + signingMethod = jwt.SigningMethodHS384 + case "HS512": + signingMethod = jwt.SigningMethodHS512 + + case "RS256": + signingMethod = jwt.SigningMethodRS256 + case "RS384": + signingMethod = jwt.SigningMethodRS384 + case "RS512": + signingMethod = jwt.SigningMethodRS512 + + case "ES256": + signingMethod = jwt.SigningMethodES256 + case "ES384": + signingMethod = jwt.SigningMethodES384 + case "ES512": + signingMethod = jwt.SigningMethodES512 + case "EdDSA": + signingMethod = jwt.SigningMethodEdDSA + default: + return nil, ErrInvalidAlgorithmType{algorithm} + } + + switch signingMethod.(type) { + case *jwt.SigningMethodEd25519: + privateKey, ok := key.(ed25519.PrivateKey) + if !ok { + return nil, jwt.ErrInvalidKeyType + } + return newEdDSASingingKey(signingMethod, privateKey) + case *jwt.SigningMethodECDSA: + privateKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, jwt.ErrInvalidKeyType + } + return newECDSASingingKey(signingMethod, privateKey) + case *jwt.SigningMethodRSA: + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, jwt.ErrInvalidKeyType + } + return newRSASingingKey(signingMethod, privateKey) + default: + secret, ok := key.([]byte) + if !ok { + return nil, jwt.ErrInvalidKeyType + } + return hmacSigningKey{signingMethod, secret}, nil + } +} + +// DefaultSigningKey is the default signing key for JWTs. +var DefaultSigningKey JWTSigningKey + +// InitSigningKey creates the default signing key from settings or creates a random key. +func InitSigningKey() error { + var err error + var key any + + switch setting.OAuth2.JWTSigningAlgorithm { + case "HS256": + fallthrough + case "HS384": + fallthrough + case "HS512": + key = setting.GetGeneralTokenSigningSecret() + case "RS256": + fallthrough + case "RS384": + fallthrough + case "RS512": + fallthrough + case "ES256": + fallthrough + case "ES384": + fallthrough + case "ES512": + fallthrough + case "EdDSA": + key, err = loadOrCreateAsymmetricKey() + default: + return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm} + } + + if err != nil { + return fmt.Errorf("Error while loading or creating JWT key: %w", err) + } + + signingKey, err := CreateJWTSigningKey(setting.OAuth2.JWTSigningAlgorithm, key) + if err != nil { + return err + } + + DefaultSigningKey = signingKey + + return nil +} + +// loadOrCreateAsymmetricKey checks if the configured private key exists. +// If it does not exist a new random key gets generated and saved on the configured path. +func loadOrCreateAsymmetricKey() (any, error) { + keyPath := setting.OAuth2.JWTSigningPrivateKeyFile + + isExist, err := util.IsExist(keyPath) + if err != nil { + log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err) + } + if !isExist { + err := func() error { + key, err := func() (any, error) { + switch { + case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"): + return rsa.GenerateKey(rand.Reader, 4096) + case setting.OAuth2.JWTSigningAlgorithm == "EdDSA": + _, pk, err := ed25519.GenerateKey(rand.Reader) + return pk, err + default: + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + } + }() + if err != nil { + return err + } + + bytes, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return err + } + + privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: bytes} + + if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil { + return err + } + + f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + if err = f.Close(); err != nil { + log.Error("Close: %v", err) + } + }() + + return pem.Encode(f, privateKeyPEM) + }() + if err != nil { + log.Fatal("Error generating private key: %v", err) + return nil, err + } + } + + bytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(bytes) + if block == nil { + return nil, fmt.Errorf("no valid PEM data found in %s", keyPath) + } else if block.Type != "PRIVATE KEY" { + return nil, fmt.Errorf("expected PRIVATE KEY, got %s in %s", block.Type, keyPath) + } + + return x509.ParsePKCS8PrivateKey(block.Bytes) +} diff --git a/services/auth/source/oauth2/main_test.go b/services/auth/source/oauth2/main_test.go new file mode 100644 index 0000000..57c74fd --- /dev/null +++ b/services/auth/source/oauth2/main_test.go @@ -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{}) +} diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go new file mode 100644 index 0000000..f2c1bb4 --- /dev/null +++ b/services/auth/source/oauth2/providers.go @@ -0,0 +1,190 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + "errors" + "fmt" + "html" + "html/template" + "net/url" + "sort" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + + "github.com/markbates/goth" +) + +// Provider is an interface for describing a single OAuth2 provider +type Provider interface { + Name() string + DisplayName() string + IconHTML(size int) template.HTML + CustomURLSettings() *CustomURLSettings +} + +// 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 tw-mr-2" 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{} + +// 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 +} + +// GetSupportedOAuth2Providers returns the map of unconfigured OAuth2 providers +// key is used as technical name (like in the callbackURL) +// values to display +func GetSupportedOAuth2Providers() []Provider { + providers := make([]Provider, 0, len(gothProviders)) + + for _, provider := range gothProviders { + 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() +} + +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 +} diff --git a/services/auth/source/oauth2/providers_base.go b/services/auth/source/oauth2/providers_base.go new file mode 100644 index 0000000..9d4ab10 --- /dev/null +++ b/services/auth/source/oauth2/providers_base.go @@ -0,0 +1,51 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "html/template" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/svg" +) + +// BaseProvider represents a common base for Provider +type BaseProvider struct { + name string + displayName string +} + +// 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, "tw-mr-2") + if svgHTML == "" { + log.Error("No SVG icon for oauth2 provider %q", b.name) + svgHTML = svg.RenderHTML("gitea-openid", size, "tw-mr-2") + } + return svgHTML +} + +// CustomURLSettings returns the custom url settings for this provider +func (b *BaseProvider) CustomURLSettings() *CustomURLSettings { + return nil +} + +var _ Provider = &BaseProvider{} diff --git a/services/auth/source/oauth2/providers_custom.go b/services/auth/source/oauth2/providers_custom.go new file mode 100644 index 0000000..65cf538 --- /dev/null +++ b/services/auth/source/oauth2/providers_custom.go @@ -0,0 +1,123 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "code.gitea.io/gitea/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 + }, + )) +} diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go new file mode 100644 index 0000000..285876d --- /dev/null +++ b/services/auth/source/oauth2/providers_openid.go @@ -0,0 +1,58 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "html/template" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/openidConnect" +) + +// OpenIDProvider is a GothProvider for OpenID +type OpenIDProvider struct{} + +// 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, "tw-mr-2") +} + +// 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 provider, err +} + +// CustomURLSettings returns the custom url settings for this provider +func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings { + return nil +} + +var _ GothProvider = &OpenIDProvider{} + +func init() { + RegisterGothProvider(&OpenIDProvider{}) +} diff --git a/services/auth/source/oauth2/providers_simple.go b/services/auth/source/oauth2/providers_simple.go new file mode 100644 index 0000000..e95323a --- /dev/null +++ b/services/auth/source/oauth2/providers_simple.go @@ -0,0 +1,109 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "code.gitea.io/gitea/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...) + }, + )) +} diff --git a/services/auth/source/oauth2/providers_test.go b/services/auth/source/oauth2/providers_test.go new file mode 100644 index 0000000..353816c --- /dev/null +++ b/services/auth/source/oauth2/providers_test.go @@ -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{} + })) +} diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go new file mode 100644 index 0000000..3454c9a --- /dev/null +++ b/services/auth/source/oauth2/source.go @@ -0,0 +1,51 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" +) + +// Source holds configuration for the OAuth2 login source. +type Source struct { + 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 + SkipLocalTwoFA bool `json:",omitempty"` + + // reference to the authSource + authSource *auth.Source +} + +// 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) +} + +// SetAuthSource sets the related AuthSource +func (source *Source) SetAuthSource(authSource *auth.Source) { + source.authSource = authSource +} + +func init() { + auth.RegisterTypeConfig(auth.OAuth2, &Source{}) +} diff --git a/services/auth/source/oauth2/source_authenticate.go b/services/auth/source/oauth2/source_authenticate.go new file mode 100644 index 0000000..bbda35d --- /dev/null +++ b/services/auth/source/oauth2/source_authenticate.go @@ -0,0 +1,19 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "context" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/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 diff --git a/services/auth/source/oauth2/source_callout.go b/services/auth/source/oauth2/source_callout.go new file mode 100644 index 0000000..f95a80f --- /dev/null +++ b/services/auth/source/oauth2/source_callout.go @@ -0,0 +1,68 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "net/http" + "net/url" + + "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, codeChallengeS256 string) error { + // not sure if goth is thread safe (?) when using multiple providers + request.Header.Set(ProviderHeaderKey, source.authSource.Name) + + var querySuffix string + if codeChallengeS256 != "" { + querySuffix = "&" + url.Values{ + "code_challenge_method": []string{"S256"}, + "code_challenge": []string{codeChallengeS256}, + }.Encode() + } + + // 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 { + // hacky way to set the code_challenge, but no better way until + // https://github.com/markbates/goth/issues/516 is resolved + http.Redirect(response, request, url+querySuffix, 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, codeVerifier string) (goth.User, error) { + // not sure if goth is thread safe (?) when using multiple providers + request.Header.Set(ProviderHeaderKey, source.authSource.Name) + + if codeVerifier != "" { + // hacky way to set the code_verifier... + // Will be picked up inside CompleteUserAuth: params := req.URL.Query() + // https://github.com/markbates/goth/pull/474/files + request = request.Clone(request.Context()) + q := request.URL.Query() + q.Add("code_verifier", codeVerifier) + request.URL.RawQuery = q.Encode() + } + + gothRWMutex.RLock() + defer gothRWMutex.RUnlock() + + user, err := gothic.CompleteUserAuth(response, request) + if err != nil { + return user, err + } + + return user, nil +} diff --git a/services/auth/source/oauth2/source_name.go b/services/auth/source/oauth2/source_name.go new file mode 100644 index 0000000..eee789e --- /dev/null +++ b/services/auth/source/oauth2/source_name.go @@ -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() +} diff --git a/services/auth/source/oauth2/source_register.go b/services/auth/source/oauth2/source_register.go new file mode 100644 index 0000000..82a36ac --- /dev/null +++ b/services/auth/source/oauth2/source_register.go @@ -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 +} diff --git a/services/auth/source/oauth2/source_sync.go b/services/auth/source/oauth2/source_sync.go new file mode 100644 index 0000000..5e30313 --- /dev/null +++ b/services/auth/source/oauth2/source_sync.go @@ -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 +} diff --git a/services/auth/source/oauth2/source_sync_test.go b/services/auth/source/oauth2/source_sync_test.go new file mode 100644 index 0000000..746df82 --- /dev/null +++ b/services/auth/source/oauth2/source_sync_test.go @@ -0,0 +1,101 @@ +// 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) + }) + }) +} diff --git a/services/auth/source/oauth2/store.go b/services/auth/source/oauth2/store.go new file mode 100644 index 0000000..e031653 --- /dev/null +++ b/services/auth/source/oauth2/store.go @@ -0,0 +1,98 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "encoding/gob" + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/log" + session_module "code.gitea.io/gitea/modules/session" + + chiSession "code.forgejo.org/go-chi/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) { + chiStore := chiSession.GetSession(r) + + session := sessions.NewSession(st, name) + + rawData := chiStore.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 = chiStore.ID() // Simply copy the session id from the chi store + + return session, chiStore.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 { + chiStore := chiSession.GetSession(r) + + if session.IsNew { + _, _ = session_module.RegenerateSession(w, r) + session.IsNew = false + } + + if err := chiStore.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 chiStore.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{} diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go new file mode 100644 index 0000000..3405619 --- /dev/null +++ b/services/auth/source/oauth2/token.go @@ -0,0 +1,100 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "fmt" + "time" + + "code.gitea.io/gitea/modules/timeutil" + + "github.com/golang-jwt/jwt/v5" +) + +// ___________ __ +// \__ ___/___ | | __ ____ ____ +// | | / _ \| |/ // __ \ / \ +// | |( <_> ) <\ ___/| | \ +// |____| \____/|__|_ \\___ >___| / +// \/ \/ \/ + +// Token represents an Oauth grant + +// TokenType represents the type of token for an oauth application +type TokenType int + +const ( + // TypeAccessToken is a token with short lifetime to access the api + TypeAccessToken TokenType = 0 + // TypeRefreshToken is token with long lifetime to refresh access tokens obtained by the client + TypeRefreshToken = iota +) + +// Token represents a JWT token used to authenticate a client +type Token struct { + GrantID int64 `json:"gnt"` + Type TokenType `json:"tt"` + Counter int64 `json:"cnt,omitempty"` + jwt.RegisteredClaims +} + +// ParseToken parses a signed jwt string +func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) { + parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (any, error) { + if token.Method == nil || token.Method.Alg() != signingKey.SigningMethod().Alg() { + return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"]) + } + return signingKey.VerifyKey(), nil + }) + if err != nil { + return nil, err + } + if !parsedToken.Valid { + return nil, fmt.Errorf("invalid token") + } + var token *Token + var ok bool + if token, ok = parsedToken.Claims.(*Token); !ok || !parsedToken.Valid { + return nil, fmt.Errorf("invalid token") + } + return token, nil +} + +// SignToken signs the token with the JWT secret +func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) { + token.IssuedAt = jwt.NewNumericDate(time.Now()) + jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token) + signingKey.PreProcessToken(jwtToken) + return jwtToken.SignedString(signingKey.SignKey()) +} + +// OIDCToken represents an OpenID Connect id_token +type OIDCToken struct { + jwt.RegisteredClaims + Nonce string `json:"nonce,omitempty"` + + // Scope profile + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Profile string `json:"profile,omitempty"` + Picture string `json:"picture,omitempty"` + Website string `json:"website,omitempty"` + Locale string `json:"locale,omitempty"` + UpdatedAt timeutil.TimeStamp `json:"updated_at,omitempty"` + + // Scope email + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + + // Groups are generated by organization and team names + Groups []string `json:"groups,omitempty"` +} + +// SignToken signs an id_token with the (symmetric) client secret key +func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) { + token.IssuedAt = jwt.NewNumericDate(time.Now()) + jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token) + signingKey.PreProcessToken(jwtToken) + return jwtToken.SignedString(signingKey.SignKey()) +} diff --git a/services/auth/source/oauth2/urlmapping.go b/services/auth/source/oauth2/urlmapping.go new file mode 100644 index 0000000..d0442d5 --- /dev/null +++ b/services/auth/source/oauth2/urlmapping.go @@ -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 `json:",omitempty"` + TokenURL Attribute `json:",omitempty"` + ProfileURL Attribute `json:",omitempty"` + EmailURL Attribute `json:",omitempty"` + Tenant Attribute `json:",omitempty"` +} + +// 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 +} |