mirror of https://github.com/go-gitea/gitea
Support SAML authentication (#25165)
Closes https://github.com/go-gitea/gitea/issues/5512 This PR adds basic SAML support - Adds SAML 2.0 as an auth source - Adds SAML configuration documentation - Adds integration test: - Use bare-bones SAML IdP to test protocol flow and test account is linked successfully (only runs on Postgres by default) - Adds documentation for configuring and running SAML integration test locally Future PRs: - Support group mapping - Support auto-registration (account linking) Co-Authored-By: @jackHay22 --------- Co-authored-by: jackHay22 <jack@allspice.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: KN4CK3R <admin@oldschoolhack.me> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Jason Song <i@wolfogre.com> Co-authored-by: morphelinho <morphelinho@users.noreply.github.com> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Yarden Shoham <git@yardenshoham.com> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: silverwind <me@silverwind.io>pull/29337/head
parent
c4b0cb4d0d
commit
5bb8d1924d
File diff suppressed because one or more lines are too long
@ -0,0 +1,172 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/models/auth" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/modules/web/middleware" |
||||
"code.gitea.io/gitea/services/auth/source/saml" |
||||
"code.gitea.io/gitea/services/externalaccount" |
||||
|
||||
"github.com/markbates/goth" |
||||
) |
||||
|
||||
func SignInSAML(ctx *context.Context) { |
||||
provider := ctx.Params(":provider") |
||||
|
||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) |
||||
if err != nil || loginSource == nil { |
||||
ctx.NotFound("SAMLMetadata", err) |
||||
return |
||||
} |
||||
|
||||
if err = loginSource.Cfg.(*saml.Source).Callout(ctx.Req, ctx.Resp); err != nil { |
||||
if strings.Contains(err.Error(), "no provider for ") { |
||||
ctx.Error(http.StatusNotFound) |
||||
return |
||||
} |
||||
ctx.ServerError("SignIn", err) |
||||
} |
||||
} |
||||
|
||||
func SignInSAMLCallback(ctx *context.Context) { |
||||
provider := ctx.Params(":provider") |
||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) |
||||
if err != nil || loginSource == nil { |
||||
ctx.NotFound("SignInSAMLCallback", err) |
||||
return |
||||
} |
||||
|
||||
if loginSource == nil { |
||||
ctx.ServerError("SignIn", fmt.Errorf("no valid provider found, check configured callback url in provider")) |
||||
return |
||||
} |
||||
|
||||
u, gothUser, err := samlUserLoginCallback(*ctx, loginSource, ctx.Req, ctx.Resp) |
||||
if err != nil { |
||||
ctx.ServerError("SignInSAMLCallback", err) |
||||
return |
||||
} |
||||
|
||||
if u == nil { |
||||
if ctx.Doer != nil { |
||||
// attach user to already logged in user
|
||||
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.SAML) |
||||
if err != nil { |
||||
ctx.ServerError("LinkAccountToUser", err) |
||||
return |
||||
} |
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security") |
||||
return |
||||
} else if !setting.Service.AllowOnlyInternalRegistration && false { |
||||
// TODO: allow auto registration from saml users (OAuth2 uses the following setting.OAuth2Client.EnableAutoRegistration)
|
||||
} else { |
||||
// no existing user is found, request attach or new account
|
||||
showLinkingLogin(ctx, gothUser, auth.SAML) |
||||
return |
||||
} |
||||
} |
||||
|
||||
handleSamlSignIn(ctx, loginSource, u, gothUser) |
||||
} |
||||
|
||||
func handleSamlSignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { |
||||
if err := updateSession(ctx, nil, map[string]any{ |
||||
"uid": u.ID, |
||||
"uname": u.Name, |
||||
}); err != nil { |
||||
ctx.ServerError("updateSession", err) |
||||
return |
||||
} |
||||
|
||||
// Clear whatever CSRF cookie has right now, force to generate a new one
|
||||
ctx.Csrf.DeleteCookie(ctx) |
||||
|
||||
// Register last login
|
||||
u.SetLastLogin() |
||||
|
||||
// update external user information
|
||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.SAML); err != nil { |
||||
if !errors.Is(err, util.ErrNotExist) { |
||||
log.Error("UpdateExternalUser failed: %v", err) |
||||
} |
||||
} |
||||
|
||||
if err := resetLocale(ctx, u); err != nil { |
||||
ctx.ServerError("resetLocale", err) |
||||
return |
||||
} |
||||
|
||||
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { |
||||
middleware.DeleteRedirectToCookie(ctx.Resp) |
||||
ctx.RedirectToFirst(redirectTo) |
||||
return |
||||
} |
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/") |
||||
} |
||||
|
||||
func samlUserLoginCallback(ctx context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { |
||||
samlSource := authSource.Cfg.(*saml.Source) |
||||
|
||||
gothUser, err := samlSource.Callback(request, response) |
||||
if err != nil { |
||||
return nil, gothUser, err |
||||
} |
||||
|
||||
user := &user_model.User{ |
||||
LoginName: gothUser.UserID, |
||||
LoginType: auth.SAML, |
||||
LoginSource: authSource.ID, |
||||
} |
||||
|
||||
hasUser, err := user_model.GetUser(ctx, user) |
||||
if err != nil { |
||||
return nil, goth.User{}, err |
||||
} |
||||
|
||||
if hasUser { |
||||
return user, gothUser, nil |
||||
} |
||||
|
||||
// search in external linked users
|
||||
externalLoginUser := &user_model.ExternalLoginUser{ |
||||
ExternalID: gothUser.UserID, |
||||
LoginSourceID: authSource.ID, |
||||
} |
||||
hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) |
||||
if err != nil { |
||||
return nil, goth.User{}, err |
||||
} |
||||
if hasUser { |
||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID) |
||||
return user, gothUser, err |
||||
} |
||||
|
||||
// no user found to login
|
||||
return nil, gothUser, nil |
||||
} |
||||
|
||||
func SAMLMetadata(ctx *context.Context) { |
||||
provider := ctx.Params(":provider") |
||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) |
||||
if err != nil || loginSource == nil { |
||||
ctx.NotFound("SAMLMetadata", err) |
||||
return |
||||
} |
||||
if err = loginSource.Cfg.(*saml.Source).Metadata(ctx.Req, ctx.Resp); err != nil { |
||||
ctx.ServerError("SAMLMetadata", err) |
||||
} |
||||
} |
@ -0,0 +1,22 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml_test |
||||
|
||||
import ( |
||||
auth_model "code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/services/auth" |
||||
"code.gitea.io/gitea/services/auth/source/saml" |
||||
) |
||||
|
||||
// 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) = &saml.Source{} |
@ -0,0 +1,29 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
import ( |
||||
"context" |
||||
"sync" |
||||
|
||||
"code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/modules/log" |
||||
) |
||||
|
||||
var samlRWMutex = sync.RWMutex{} |
||||
|
||||
func Init(ctx context.Context) error { |
||||
loginSources, _ := auth.GetActiveAuthProviderSources(ctx, auth.SAML) |
||||
for _, source := range loginSources { |
||||
samlSource, ok := source.Cfg.(*Source) |
||||
if !ok { |
||||
continue |
||||
} |
||||
err := samlSource.RegisterSource() |
||||
if err != nil { |
||||
log.Error("Unable to register source: %s due to Error: %v.", source.Name, err) |
||||
} |
||||
} |
||||
return nil |
||||
} |
@ -0,0 +1,38 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
type NameIDFormat int |
||||
|
||||
const ( |
||||
SAML11Email NameIDFormat = iota + 1 |
||||
SAML11Persistent |
||||
SAML11Unspecified |
||||
SAML20Email |
||||
SAML20Persistent |
||||
SAML20Transient |
||||
SAML20Unspecified |
||||
) |
||||
|
||||
const DefaultNameIDFormat NameIDFormat = SAML20Persistent |
||||
|
||||
var NameIDFormatNames = map[NameIDFormat]string{ |
||||
SAML11Email: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", |
||||
SAML11Persistent: "urn:oasis:names:tc:SAML:1.1:nameid-format:persistent", |
||||
SAML11Unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", |
||||
SAML20Email: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", |
||||
SAML20Persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", |
||||
SAML20Transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", |
||||
SAML20Unspecified: "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified", |
||||
} |
||||
|
||||
// String returns the name of the NameIDFormat
|
||||
func (n NameIDFormat) String() string { |
||||
return NameIDFormatNames[n] |
||||
} |
||||
|
||||
// Int returns the int value of the NameIDFormat
|
||||
func (n NameIDFormat) Int() int { |
||||
return int(n) |
||||
} |
@ -0,0 +1,109 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"html" |
||||
"html/template" |
||||
"io" |
||||
"net/http" |
||||
"sort" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/httplib" |
||||
"code.gitea.io/gitea/modules/svg" |
||||
"code.gitea.io/gitea/modules/util" |
||||
) |
||||
|
||||
// Providers is list of known/available providers.
|
||||
type Providers map[string]Source |
||||
|
||||
var providers = Providers{} |
||||
|
||||
// Provider is an interface for describing a single SAML provider
|
||||
type Provider interface { |
||||
Name() string |
||||
IconHTML(size int) template.HTML |
||||
} |
||||
|
||||
// AuthSourceProvider is a SAML provider
|
||||
type AuthSourceProvider struct { |
||||
sourceName, iconURL string |
||||
} |
||||
|
||||
func (p *AuthSourceProvider) Name() string { |
||||
return p.sourceName |
||||
} |
||||
|
||||
func (p *AuthSourceProvider) IconHTML(size int) template.HTML { |
||||
if p.iconURL != "" { |
||||
return template.HTML(fmt.Sprintf(`<img class="gt-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`, |
||||
size, |
||||
size, |
||||
html.EscapeString(p.iconURL), html.EscapeString(p.Name()), |
||||
)) |
||||
} |
||||
return svg.RenderHTML("gitea-lock-cog", size, "gt-mr-3") |
||||
} |
||||
|
||||
func readIdentityProviderMetadata(ctx context.Context, source *Source) ([]byte, error) { |
||||
if source.IdentityProviderMetadata != "" { |
||||
return []byte(source.IdentityProviderMetadata), nil |
||||
} |
||||
|
||||
req := httplib.NewRequest(source.IdentityProviderMetadataURL, "GET") |
||||
req.SetTimeout(20*time.Second, time.Minute) |
||||
resp, err := req.Response() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("Unable to contact gitea: %v", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, err |
||||
} |
||||
|
||||
data, err := io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return data, nil |
||||
} |
||||
|
||||
func createProviderFromSource(source *auth.Source) (Provider, error) { |
||||
samlCfg, ok := source.Cfg.(*Source) |
||||
if !ok { |
||||
return nil, fmt.Errorf("invalid SAML source config: %v", samlCfg) |
||||
} |
||||
return &AuthSourceProvider{sourceName: source.Name, iconURL: samlCfg.IconURL}, nil |
||||
} |
||||
|
||||
// GetSAMLProviders returns the list of configured SAML providers
|
||||
func GetSAMLProviders(ctx context.Context, isActive util.OptionalBool) ([]Provider, error) { |
||||
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ |
||||
IsActive: isActive, |
||||
LoginType: auth.SAML, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
samlProviders := make([]Provider, 0, len(authSources)) |
||||
for _, source := range authSources { |
||||
p, err := createProviderFromSource(source) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
samlProviders = append(samlProviders, p) |
||||
} |
||||
|
||||
sort.Slice(samlProviders, func(i, j int) bool { |
||||
return samlProviders[i].Name() < samlProviders[j].Name() |
||||
}) |
||||
|
||||
return samlProviders, nil |
||||
} |
@ -0,0 +1,202 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/rand" |
||||
"crypto/rsa" |
||||
"crypto/tls" |
||||
"crypto/x509" |
||||
"encoding/base64" |
||||
"encoding/pem" |
||||
"encoding/xml" |
||||
"errors" |
||||
"fmt" |
||||
"math/big" |
||||
"net/url" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/modules/json" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
|
||||
saml2 "github.com/russellhaering/gosaml2" |
||||
"github.com/russellhaering/gosaml2/types" |
||||
dsig "github.com/russellhaering/goxmldsig" |
||||
) |
||||
|
||||
// Source holds configuration for the SAML login source.
|
||||
type Source struct { |
||||
// IdentityProviderMetadata description: The SAML Identity Provider metadata XML contents (for static configuration of the SAML Service Provider). The value of this field should be an XML document whose root element is `<EntityDescriptor>` or `<EntityDescriptors>`. To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh.
|
||||
IdentityProviderMetadata string |
||||
// IdentityProviderMetadataURL description: The SAML Identity Provider metadata URL (for dynamic configuration of the SAML Service Provider).
|
||||
IdentityProviderMetadataURL string |
||||
// InsecureSkipAssertionSignatureValidation description: Whether the Service Provider should (insecurely) accept assertions from the Identity Provider without a valid signature.
|
||||
InsecureSkipAssertionSignatureValidation bool |
||||
// NameIDFormat description: The SAML NameID format to use when performing user authentication.
|
||||
NameIDFormat NameIDFormat |
||||
// ServiceProviderCertificate description: The SAML Service Provider certificate in X.509 encoding (begins with "-----BEGIN CERTIFICATE-----"). This certificate is used by the Identity Provider to validate the Service Provider's AuthnRequests and LogoutRequests. It corresponds to the Service Provider's private key (`serviceProviderPrivateKey`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh.
|
||||
ServiceProviderCertificate string |
||||
// ServiceProviderIssuer description: The SAML Service Provider name, used to identify this Service Provider. This is required if the "externalURL" field is not set (as the SAML metadata endpoint is computed as "<externalURL>.auth/saml/metadata"), or when using multiple SAML authentication providers.
|
||||
ServiceProviderIssuer string |
||||
// ServiceProviderPrivateKey description: The SAML Service Provider private key in PKCS#8 encoding (begins with "-----BEGIN PRIVATE KEY-----"). This private key is used to sign AuthnRequests and LogoutRequests. It corresponds to the Service Provider's certificate (`serviceProviderCertificate`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh.
|
||||
ServiceProviderPrivateKey string |
||||
|
||||
CallbackURL string |
||||
IconURL string |
||||
|
||||
// EmailAssertionKey description: Assertion key for user.Email
|
||||
EmailAssertionKey string |
||||
// NameAssertionKey description: Assertion key for user.NickName
|
||||
NameAssertionKey string |
||||
// UsernameAssertionKey description: Assertion key for user.Name
|
||||
UsernameAssertionKey string |
||||
|
||||
// reference to the authSource
|
||||
authSource *auth.Source |
||||
|
||||
samlSP *saml2.SAMLServiceProvider |
||||
} |
||||
|
||||
func GenerateSAMLSPKeypair() (string, string, error) { |
||||
key, err := rsa.GenerateKey(rand.Reader, 4096) |
||||
if err != nil { |
||||
return "", "", err |
||||
} |
||||
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key) |
||||
keyPem := pem.EncodeToMemory( |
||||
&pem.Block{ |
||||
Type: "RSA PRIVATE KEY", |
||||
Bytes: keyBytes, |
||||
}, |
||||
) |
||||
|
||||
now := time.Now() |
||||
|
||||
template := &x509.Certificate{ |
||||
SerialNumber: big.NewInt(0), |
||||
NotBefore: now.Add(-5 * time.Minute), |
||||
NotAfter: now.Add(365 * 24 * time.Hour), |
||||
|
||||
KeyUsage: x509.KeyUsageDigitalSignature, |
||||
ExtKeyUsage: []x509.ExtKeyUsage{}, |
||||
BasicConstraintsValid: true, |
||||
} |
||||
|
||||
certificate, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) |
||||
if err != nil { |
||||
return "", "", err |
||||
} |
||||
|
||||
certPem := pem.EncodeToMemory( |
||||
&pem.Block{ |
||||
Type: "CERTIFICATE", |
||||
Bytes: certificate, |
||||
}, |
||||
) |
||||
|
||||
return string(keyPem), string(certPem), nil |
||||
} |
||||
|
||||
func (source *Source) initSAMLSp() error { |
||||
source.CallbackURL = setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/acs" |
||||
|
||||
idpMetadata, err := readIdentityProviderMetadata(context.Background(), source) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
{ |
||||
if source.IdentityProviderMetadataURL != "" { |
||||
log.Trace(fmt.Sprintf("Identity Provider metadata: %s", source.IdentityProviderMetadataURL), string(idpMetadata)) |
||||
} |
||||
} |
||||
|
||||
metadata := &types.EntityDescriptor{} |
||||
err = xml.Unmarshal(idpMetadata, metadata) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
certStore := dsig.MemoryX509CertificateStore{ |
||||
Roots: []*x509.Certificate{}, |
||||
} |
||||
|
||||
if metadata.IDPSSODescriptor == nil { |
||||
return errors.New("saml idp metadata missing IDPSSODescriptor") |
||||
} |
||||
|
||||
for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors { |
||||
for idx, xcert := range kd.KeyInfo.X509Data.X509Certificates { |
||||
if xcert.Data == "" { |
||||
return fmt.Errorf("metadata certificate(%d) must not be empty", idx) |
||||
} |
||||
certData, err := base64.StdEncoding.DecodeString(xcert.Data) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
idpCert, err := x509.ParseCertificate(certData) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
certStore.Roots = append(certStore.Roots, idpCert) |
||||
} |
||||
} |
||||
|
||||
var keyStore dsig.X509KeyStore |
||||
|
||||
if source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "" { |
||||
keyPair, err := tls.X509KeyPair([]byte(source.ServiceProviderCertificate), []byte(source.ServiceProviderPrivateKey)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
keyStore = dsig.TLSCertKeyStore(keyPair) |
||||
} |
||||
|
||||
source.samlSP = &saml2.SAMLServiceProvider{ |
||||
IdentityProviderSSOURL: metadata.IDPSSODescriptor.SingleSignOnServices[0].Location, |
||||
IdentityProviderIssuer: metadata.EntityID, |
||||
AudienceURI: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", |
||||
AssertionConsumerServiceURL: source.CallbackURL, |
||||
SkipSignatureValidation: source.InsecureSkipAssertionSignatureValidation, |
||||
NameIdFormat: source.NameIDFormat.String(), |
||||
IDPCertificateStore: &certStore, |
||||
SignAuthnRequests: source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "", |
||||
SPKeyStore: keyStore, |
||||
ServiceProviderIssuer: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// FromDB fills up a SAML from serialized format.
|
||||
func (source *Source) FromDB(bs []byte) error { |
||||
if err := json.UnmarshalHandleDoubleEncode(bs, &source); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return source.initSAMLSp() |
||||
} |
||||
|
||||
// ToDB exports a SAML 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.SAML, &Source{}) |
||||
} |
@ -0,0 +1,16 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
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) |
||||
} |
@ -0,0 +1,89 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/markbates/goth" |
||||
) |
||||
|
||||
// Callout redirects request/response pair to authenticate against the provider
|
||||
func (source *Source) Callout(request *http.Request, response http.ResponseWriter) error { |
||||
samlRWMutex.RLock() |
||||
defer samlRWMutex.RUnlock() |
||||
if _, ok := providers[source.authSource.Name]; !ok { |
||||
return fmt.Errorf("no provider for this saml") |
||||
} |
||||
|
||||
authURL, err := providers[source.authSource.Name].samlSP.BuildAuthURL("") |
||||
if err == nil { |
||||
http.Redirect(response, request, authURL, http.StatusTemporaryRedirect) |
||||
} |
||||
return err |
||||
} |
||||
|
||||
// Callback handles SAML 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) { |
||||
samlRWMutex.RLock() |
||||
defer samlRWMutex.RUnlock() |
||||
|
||||
user := goth.User{ |
||||
Provider: source.authSource.Name, |
||||
} |
||||
samlResponse := request.FormValue("SAMLResponse") |
||||
assertions, err := source.samlSP.RetrieveAssertionInfo(samlResponse) |
||||
if err != nil { |
||||
return user, err |
||||
} |
||||
|
||||
if assertions.WarningInfo.OneTimeUse { |
||||
return user, fmt.Errorf("SAML response contains one time use warning") |
||||
} |
||||
|
||||
if assertions.WarningInfo.ProxyRestriction != nil { |
||||
return user, fmt.Errorf("SAML response contains proxy restriction warning: %v", assertions.WarningInfo.ProxyRestriction) |
||||
} |
||||
|
||||
if assertions.WarningInfo.NotInAudience { |
||||
return user, fmt.Errorf("SAML response contains audience warning") |
||||
} |
||||
|
||||
if assertions.WarningInfo.InvalidTime { |
||||
return user, fmt.Errorf("SAML response contains invalid time warning") |
||||
} |
||||
|
||||
samlMap := make(map[string]string) |
||||
for key, value := range assertions.Values { |
||||
keyParsed := strings.ToLower(key[strings.LastIndex(key, "/")+1:]) // Uses the trailing slug as the key name.
|
||||
valueParsed := value.Values[0].Value |
||||
samlMap[keyParsed] = valueParsed |
||||
|
||||
} |
||||
|
||||
user.UserID = assertions.NameID |
||||
if user.UserID == "" { |
||||
return user, fmt.Errorf("no nameID found in SAML response") |
||||
} |
||||
|
||||
// email
|
||||
if _, ok := samlMap[source.EmailAssertionKey]; !ok { |
||||
user.Email = samlMap[source.EmailAssertionKey] |
||||
} |
||||
// name
|
||||
if _, ok := samlMap[source.NameAssertionKey]; !ok { |
||||
user.NickName = samlMap[source.NameAssertionKey] |
||||
} |
||||
// username
|
||||
if _, ok := samlMap[source.UsernameAssertionKey]; !ok { |
||||
user.Name = samlMap[source.UsernameAssertionKey] |
||||
} |
||||
|
||||
// TODO: utilize groups once mapping is supported
|
||||
|
||||
return user, nil |
||||
} |
@ -0,0 +1,32 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
import ( |
||||
"encoding/xml" |
||||
"fmt" |
||||
"net/http" |
||||
) |
||||
|
||||
// Metadata redirects request/response pair to authenticate against the provider
|
||||
func (source *Source) Metadata(request *http.Request, response http.ResponseWriter) error { |
||||
samlRWMutex.RLock() |
||||
defer samlRWMutex.RUnlock() |
||||
if _, ok := providers[source.authSource.Name]; !ok { |
||||
return fmt.Errorf("provider does not exist") |
||||
} |
||||
|
||||
metadata, err := providers[source.authSource.Name].samlSP.Metadata() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
buf, err := xml.Marshal(metadata) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
response.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8") |
||||
_, _ = response.Write(buf) |
||||
return nil |
||||
} |
@ -0,0 +1,23 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package saml |
||||
|
||||
// RegisterSource causes an OAuth2 configuration to be registered
|
||||
func (source *Source) RegisterSource() error { |
||||
samlRWMutex.Lock() |
||||
defer samlRWMutex.Unlock() |
||||
if err := source.initSAMLSp(); err != nil { |
||||
return err |
||||
} |
||||
providers[source.authSource.Name] = *source |
||||
return nil |
||||
} |
||||
|
||||
// UnregisterSource causes an SAML configuration to be unregistered
|
||||
func (source *Source) UnregisterSource() error { |
||||
samlRWMutex.Lock() |
||||
defer samlRWMutex.Unlock() |
||||
delete(providers, source.authSource.Name) |
||||
return nil |
||||
} |
@ -0,0 +1,62 @@ |
||||
<div class="saml field {{if not (eq .type 8)}}gt-hidden{{end}}"> |
||||
|
||||
<div class="inline required field"> |
||||
<label>{{ctx.Locale.Tr "admin.auths.saml_nameidformat"}}</label> |
||||
<div class="ui selection type dropdown"> |
||||
<input type="hidden" id="name_id_format" name="name_id_format" value="{{.name_id_format}}"> |
||||
<div class="text">{{.CurrentNameIDFormat}}</div> |
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}} |
||||
<div class="menu"> |
||||
{{range .NameIDFormats}} |
||||
<div class="item" data-value="{{.Type.Int}}">{{.Name}}</div> |
||||
{{end}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="optional field"> |
||||
<label for="saml_icon_url">{{ctx.Locale.Tr "admin.auths.saml_icon_url"}}</label> |
||||
<input id="saml_icon_url" name="saml_icon_url" value="{{.SAMLIconURL}}"> |
||||
</div> |
||||
|
||||
<div class="field"> |
||||
<label for="identity_provider_metadata_url">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_metadata_url"}}</label> |
||||
<input id="identity_provider_metadata_url" name="identity_provider_metadata_url" value="{{.IdentityProviderMetadataURL}}"> |
||||
</div> |
||||
<div class="field"> |
||||
<label for="identity_provider_metadata">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_metadata"}}</label> |
||||
<textarea rows=2 id="identity_provider_metadata" name="identity_provider_metadata" value="{{.IdentityProviderMetadata}}"></textarea> |
||||
</div> |
||||
|
||||
<div class="inline field"> |
||||
<div class="ui checkbox"> |
||||
<label><strong>{{ctx.Locale.Tr "admin.auths.saml_insecure_skip_assertion_signature_validation"}}</strong></label> |
||||
<input name="insecure_skip_assertion_signature_validation" type="checkbox" {{if .InsecureSkipAssertionSignatureValidation}}checked{{end}}> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="field"> |
||||
<label for="service_provider_certificate">{{ctx.Locale.Tr "admin.auths.saml_service_provider_certificate"}}</label> |
||||
<textarea rows=2 id="service_provider_certificate" name="service_provider_certificate" value="{{.ServiceProviderCertificate}}"></textarea> |
||||
</div> |
||||
<div class="field"> |
||||
<label for="service_provider_private_key">{{ctx.Locale.Tr "admin.auths.saml_service_provider_private_key"}}</label> |
||||
<textarea rows=2 id="service_provider_private_key" name="service_provider_private_key" value="{{.ServiceProviderPrivateKey}}"></textarea> |
||||
</div> |
||||
|
||||
<div class="field"> |
||||
<label for="email_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_email_assertion_key"}}</label> |
||||
<input id="email_assertion_key" name="email_assertion_key" value="{{if not .EmailAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress{{else}}{{.EmailAssertionKey}}{{end}}"> |
||||
</div> |
||||
|
||||
<div class="field"> |
||||
<label for="name_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_name_assertion_key"}}</label> |
||||
<input id="name_assertion_key" name="name_assertion_key" value="{{if not .NameAssertionKey}}http://schemas.xmlsoap.org/claims/CommonName{{else}}{{.NameAssertionKey}}{{end}}"> |
||||
</div> |
||||
|
||||
<div class="field"> |
||||
<label for="username_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_username_assertion_key"}}</label> |
||||
<input id="username_assertion_key" name="username_assertion_key" value="{{if not .UsernameAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name{{else}}{{.UsernameAssertionKey}}{{end}}"> |
||||
</div> |
||||
|
||||
</div> |
@ -0,0 +1,150 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration |
||||
|
||||
import ( |
||||
"crypto/tls" |
||||
"crypto/x509" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"net/http/cookiejar" |
||||
"net/url" |
||||
"os" |
||||
"regexp" |
||||
"strings" |
||||
"testing" |
||||
"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/setting" |
||||
"code.gitea.io/gitea/modules/test" |
||||
"code.gitea.io/gitea/services/auth/source/saml" |
||||
"code.gitea.io/gitea/tests" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestSAMLRegistration(t *testing.T) { |
||||
defer tests.PrepareTestEnv(t)() |
||||
|
||||
samlURL := "localhost:8080" |
||||
|
||||
if os.Getenv("CI") == "" || !setting.Database.Type.IsPostgreSQL() { |
||||
// Make it possible to run tests against a local simplesaml instance
|
||||
samlURL = os.Getenv("TEST_SIMPLESAML_URL") |
||||
if samlURL == "" { |
||||
t.Skip("TEST_SIMPLESAML_URL not set and not running in CI") |
||||
return |
||||
} |
||||
} |
||||
|
||||
privateKey, cert, err := saml.GenerateSAMLSPKeypair() |
||||
assert.NoError(t, err) |
||||
|
||||
// verify that the keypair can be parsed
|
||||
keyPair, err := tls.X509KeyPair([]byte(cert), []byte(privateKey)) |
||||
assert.NoError(t, err) |
||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.NoError(t, auth.CreateSource(db.DefaultContext, &auth.Source{ |
||||
Type: auth.SAML, |
||||
Name: "test-sp", |
||||
IsActive: true, |
||||
IsSyncEnabled: false, |
||||
Cfg: &saml.Source{ |
||||
IdentityProviderMetadata: "", |
||||
IdentityProviderMetadataURL: fmt.Sprintf("http://%s/simplesaml/saml2/idp/metadata.php", samlURL), |
||||
InsecureSkipAssertionSignatureValidation: false, |
||||
NameIDFormat: 4, |
||||
ServiceProviderCertificate: "", // SimpleSAMLPhp requires that the SP certificate be specified in the server configuration rather than SP metadata
|
||||
ServiceProviderPrivateKey: "", |
||||
EmailAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", |
||||
NameAssertionKey: "http://schemas.xmlsoap.org/claims/CommonName", |
||||
UsernameAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", |
||||
IconURL: "", |
||||
}, |
||||
})) |
||||
|
||||
// check the saml metadata url
|
||||
req := NewRequest(t, "GET", "/user/saml/test-sp/metadata") |
||||
MakeRequest(t, req, http.StatusOK) |
||||
|
||||
req = NewRequest(t, "GET", "/user/saml/test-sp") |
||||
resp := MakeRequest(t, req, http.StatusTemporaryRedirect) |
||||
|
||||
jar, err := cookiejar.New(nil) |
||||
assert.NoError(t, err) |
||||
|
||||
client := http.Client{ |
||||
Timeout: 30 * time.Second, |
||||
Jar: jar, |
||||
} |
||||
|
||||
httpReq, err := http.NewRequest("GET", test.RedirectURL(resp), nil) |
||||
assert.NoError(t, err) |
||||
|
||||
var formRedirectURL *url.URL |
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error { |
||||
// capture the redirected destination to use in POST request
|
||||
formRedirectURL = req.URL |
||||
return nil |
||||
} |
||||
|
||||
res, err := client.Do(httpReq) |
||||
client.CheckRedirect = nil |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, http.StatusOK, res.StatusCode) |
||||
assert.NotNil(t, formRedirectURL) |
||||
|
||||
form := url.Values{ |
||||
"username": {"user1"}, |
||||
"password": {"user1pass"}, |
||||
} |
||||
|
||||
httpReq, err = http.NewRequest("POST", formRedirectURL.String(), strings.NewReader(form.Encode())) |
||||
assert.NoError(t, err) |
||||
httpReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") |
||||
|
||||
res, err = client.Do(httpReq) |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, http.StatusOK, res.StatusCode) |
||||
|
||||
body, err := io.ReadAll(res.Body) |
||||
assert.NoError(t, err) |
||||
|
||||
samlResMatcher := regexp.MustCompile(`<input.*?name="SAMLResponse".*?value="([^"]+)".*?>`) |
||||
matches := samlResMatcher.FindStringSubmatch(string(body)) |
||||
assert.Len(t, matches, 2) |
||||
assert.NoError(t, res.Body.Close()) |
||||
|
||||
session := emptyTestSession(t) |
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user/saml/test-sp/acs", map[string]string{ |
||||
"SAMLResponse": matches[1], |
||||
}) |
||||
resp = session.MakeRequest(t, req, http.StatusSeeOther) |
||||
assert.Equal(t, test.RedirectURL(resp), "/user/link_account") |
||||
|
||||
csrf := GetCSRF(t, session, test.RedirectURL(resp)) |
||||
|
||||
// link the account
|
||||
req = NewRequestWithValues(t, "POST", "/user/link_account_signup", map[string]string{ |
||||
"_csrf": csrf, |
||||
"user_name": "samluser", |
||||
"email": "saml@example.com", |
||||
}) |
||||
|
||||
resp = session.MakeRequest(t, req, http.StatusSeeOther) |
||||
assert.Equal(t, test.RedirectURL(resp), "/") |
||||
|
||||
// verify that the user was created
|
||||
u, err := user_model.GetUserByEmail(db.DefaultContext, "saml@example.com") |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, u) |
||||
assert.Equal(t, "samluser", u.Name) |
||||
} |
Loading…
Reference in new issue