初始提交: Gitea 项目代码
This commit is contained in:
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{}
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user