From 58f0598388027094f5234ace2adc5caf65523017 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Thu, 8 Jun 2023 23:25:04 -0400 Subject: [PATCH 01/84] feat(auth): add SAML --- models/auth/saml.go | 27 ++++ models/auth/source.go | 7 + routers/init.go | 2 + routers/web/admin/auths.go | 77 +++++++++++ routers/web/auth/saml.go | 48 +++++++ routers/web/web.go | 5 + .../auth/source/saml/assert_interface_test.go | 19 +++ services/auth/source/saml/init.go | 25 ++++ services/auth/source/saml/name_id_format.go | 35 +++++ services/auth/source/saml/providers.go | 124 ++++++++++++++++++ services/auth/source/saml/source.go | 61 +++++++++ .../auth/source/saml/source_authenticate.go | 11 ++ services/auth/source/saml/source_callout.go | 31 +++++ services/auth/source/saml/source_metadata.go | 29 ++++ services/auth/source/saml/source_register.go | 23 ++++ services/forms/auth_form.go | 11 +- templates/admin/auth/edit.tmpl | 50 +++++++ templates/admin/auth/new.tmpl | 3 + templates/admin/auth/source/saml.tmpl | 49 +++++++ web_src/js/features/admin/common.js | 4 +- 20 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 models/auth/saml.go create mode 100644 routers/web/auth/saml.go create mode 100644 services/auth/source/saml/assert_interface_test.go create mode 100644 services/auth/source/saml/init.go create mode 100644 services/auth/source/saml/name_id_format.go create mode 100644 services/auth/source/saml/providers.go create mode 100644 services/auth/source/saml/source.go create mode 100644 services/auth/source/saml/source_authenticate.go create mode 100644 services/auth/source/saml/source_callout.go create mode 100644 services/auth/source/saml/source_metadata.go create mode 100644 services/auth/source/saml/source_register.go create mode 100644 templates/admin/auth/source/saml.tmpl diff --git a/models/auth/saml.go b/models/auth/saml.go new file mode 100644 index 0000000000000..e0dca70ec7d1b --- /dev/null +++ b/models/auth/saml.go @@ -0,0 +1,27 @@ +package auth + +import ( + "code.gitea.io/gitea/models/db" + + auth_model "code.gitea.io/gitea/models/auth" +) + +// GetActiveSAMLProviderLoginSources returns all actived LoginSAML sources +func GetActiveSAMLProviderLoginSources() ([]*auth_model.Source, error) { + sources := make([]*auth_model.Source, 0, 1) + if err := db.GetEngine(db.DefaultContext).Where("is_active = ? and type = ?", true, auth_model.SAML).Find(&sources); err != nil { + return nil, err + } + return sources, nil +} + +// GetActiveSAMLLoginSourceByName returns a OAuth2 LoginSource based on the given name +func GetActiveSAMLLoginSourceByName(name string) (*auth_model.Source, error) { + loginSource := new(auth_model.Source) + has, err := db.GetEngine(db.DefaultContext).Where("name = ? and type = ? and is_active = ?", name, auth_model.SAML, true).Get(loginSource) + if !has || err != nil { + return nil, err + } + + return loginSource, nil +} diff --git a/models/auth/source.go b/models/auth/source.go index 0a904b7772394..f1142c714ac97 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -30,6 +30,7 @@ const ( DLDAP // 5 OAuth2 // 6 SSPI // 7 + SAML // 8 ) // String returns the string name of the LoginType @@ -50,6 +51,7 @@ var Names = map[Type]string{ PAM: "PAM", OAuth2: "OAuth2", SSPI: "SPNEGO with SSPI", + SAML: "SAML", } // Config represents login config as far as the db is concerned @@ -178,6 +180,11 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +// IsSAML returns true of this source is of the SAML type. +func (source *Source) IsSAML() bool { + return source.Type == SAML +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) diff --git a/routers/init.go b/routers/init.go index 5737ef3dc06a4..9183f33521a56 100644 --- a/routers/init.go +++ b/routers/init.go @@ -39,6 +39,7 @@ import ( actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/auth/source/saml" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/cron" "code.gitea.io/gitea/services/mailer" @@ -142,6 +143,7 @@ func GlobalInitInstalled(ctx context.Context) { log.Info("ORM engine initialization successful!") mustInit(system.Init) mustInit(oauth2.Init) + mustInit(saml.Init) mustInitCtx(ctx, models.Init) mustInit(repo_service.Init) diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index b6ea3ff40300a..1061a74f406d3 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/services/auth/source/ldap" "code.gitea.io/gitea/services/auth/source/oauth2" pam_service "code.gitea.io/gitea/services/auth/source/pam" + "code.gitea.io/gitea/services/auth/source/saml" "code.gitea.io/gitea/services/auth/source/smtp" "code.gitea.io/gitea/services/auth/source/sspi" "code.gitea.io/gitea/services/forms" @@ -71,6 +72,7 @@ var ( {auth.SMTP.String(), auth.SMTP}, {auth.OAuth2.String(), auth.OAuth2}, {auth.SSPI.String(), auth.SSPI}, + {auth.SAML.String(), auth.SAML}, } if pam.Supported { items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM}) @@ -83,6 +85,16 @@ var ( {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, } + + nameIDFormats = []dropdownItem{ + {saml.NameIDFormatNames[saml.SAML20Persistent], saml.SAML20Persistent}, // use this as default value + {saml.NameIDFormatNames[saml.SAML11Email], saml.SAML11Email}, + {saml.NameIDFormatNames[saml.SAML11Persistent], saml.SAML11Persistent}, + {saml.NameIDFormatNames[saml.SAML11Unspecified], saml.SAML11Unspecified}, + {saml.NameIDFormatNames[saml.SAML20Email], saml.SAML20Email}, + {saml.NameIDFormatNames[saml.SAML20Transient], saml.SAML20Transient}, + {saml.NameIDFormatNames[saml.SAML20Unspecified], saml.SAML20Unspecified}, + } ) // NewAuthSource render adding a new auth source page @@ -98,6 +110,8 @@ func NewAuthSource(ctx *context.Context) { ctx.Data["is_sync_enabled"] = true ctx.Data["AuthSources"] = authSources ctx.Data["SecurityProtocols"] = securityProtocols + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.SAML20Persistent] + ctx.Data["NameIDFormats"] = nameIDFormats ctx.Data["SMTPAuths"] = smtp.Authenticators oauth2providers := oauth2.GetOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers @@ -231,6 +245,48 @@ func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi }, nil } +func parseSAMLConfig(ctx *context.Context, form forms.AuthenticationForm) (*saml.Source, error) { + // TODO: verify form here + // if util.IsEmptyString(form.ServiceProviderCertificate) { + // ctx.Data["Err_SSPISeparatorReplacement"] = true + // // TODO: need sp cert + // return nil, errors.New(ctx.Tr("form.require_error")) + // } + // if util.IsEmptyString(form.ServiceProviderPrivateKey) { + // ctx.Data["Err_SSPISeparatorReplacement"] = true + // // TODO: need sp key + // return nil, errors.New(ctx.Tr("form.require_error")) + // } + if util.IsEmptyString(form.IdentityProviderMetadata) && util.IsEmptyString(form.IdentityProviderMetadataURL) { + return nil, fmt.Errorf("Identity Provider Metadata needed (either raw XML or URL)") + } + if !util.IsEmptyString(form.IdentityProviderMetadataURL) { + _, err := url.Parse(form.IdentityProviderMetadataURL) + if err != nil { + return nil, fmt.Errorf("Identity Provider Metadata URL is an invalid URL") + } + } + if !util.IsEmptyString(form.ServiceProviderCertificate) && !util.IsEmptyString(form.ServiceProviderPrivateKey) { + keyPair, err := tls.X509KeyPair([]byte(form.ServiceProviderCertificate), []byte(form.ServiceProviderPrivateKey)) + if err != nil { + return nil, err + } + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, err + } + } + return &saml.Source{ + IdentityProviderMetadata: form.IdentityProviderMetadata, + IdentityProviderMetadataURL: form.IdentityProviderMetadataURL, + InsecureSkipAssertionSignatureValidation: form.InsecureSkipAssertionSignatureValidation, + NameIDFormat: saml.NameIDFormat(form.NameIDFormat), + ServiceProviderCertificate: form.ServiceProviderCertificate, + ServiceProviderPrivateKey: form.ServiceProviderPrivateKey, + SignRequests: form.SignRequests, + }, nil +} + // NewAuthSourcePost response for adding an auth source func NewAuthSourcePost(ctx *context.Context) { form := *web.GetForm(ctx).(*forms.AuthenticationForm) @@ -244,6 +300,8 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.Data["SMTPAuths"] = smtp.Authenticators oauth2providers := oauth2.GetOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.NameIDFormat(form.NameIDFormat)] + ctx.Data["NameIDFormats"] = nameIDFormats ctx.Data["SSPIAutoCreateUsers"] = true ctx.Data["SSPIAutoActivateUsers"] = true @@ -290,6 +348,13 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) return } + case auth.SAML: + var err error + config, err = parseSAMLConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthNew, form) + return + } default: ctx.Error(http.StatusBadRequest) return @@ -336,6 +401,7 @@ func EditAuthSource(ctx *context.Context) { ctx.Data["SMTPAuths"] = smtp.Authenticators oauth2providers := oauth2.GetOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers + ctx.Data["NameIDFormats"] = nameIDFormats source, err := auth.GetSourceByID(ctx.ParamsInt64(":authid")) if err != nil { @@ -344,6 +410,9 @@ func EditAuthSource(ctx *context.Context) { } ctx.Data["Source"] = source ctx.Data["HasTLS"] = source.HasTLS() + if source.IsSAML() { + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[source.Cfg.(*saml.Source).NameIDFormat] + } if source.IsOAuth2() { type Named interface { @@ -378,6 +447,8 @@ func EditAuthSourcePost(ctx *context.Context) { } ctx.Data["Source"] = source ctx.Data["HasTLS"] = source.HasTLS() + ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.SAML20Persistent] + ctx.Data["NameIDFormats"] = nameIDFormats if ctx.HasError() { ctx.HTML(http.StatusOK, tplAuthEdit) @@ -412,6 +483,12 @@ func EditAuthSourcePost(ctx *context.Context) { ctx.RenderWithErr(err.Error(), tplAuthEdit, form) return } + case auth.SAML: + config, err = parseSAMLConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthEdit, form) + return + } default: ctx.Error(http.StatusBadRequest) return diff --git a/routers/web/auth/saml.go b/routers/web/auth/saml.go new file mode 100644 index 0000000000000..59715c966beee --- /dev/null +++ b/routers/web/auth/saml.go @@ -0,0 +1,48 @@ +package auth + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/auth/source/saml" +) + +// SignInSAML +func SignInSAML(ctx *context.Context) { + provider := ctx.Params(":provider") + + loginSource, err := auth.GetActiveSAMLLoginSourceByName(provider) + 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) + } +} + +// SignInSAMLCallback +func SignInSAMLCallback(ctx *context.Context) { + // provider := ctx.Params(":provider") + // TODO: complete SAML Callback +} + +// SAMLMetadata +func SAMLMetadata(ctx *context.Context) { + provider := ctx.Params(":provider") + loginSource, err := auth.GetActiveSAMLLoginSourceByName(provider) + 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) + } +} diff --git a/routers/web/web.go b/routers/web/web.go index f5037a848ea59..06009be662350 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -529,6 +529,11 @@ func registerRoutes(m *web.Route) { m.Get("/{provider}", auth.SignInOAuth) m.Get("/{provider}/callback", auth.SignInOAuthCallback) }) + m.Group("/saml", func() { + m.Get("/{provider}", auth.SignInSAML) // redir to SAML IDP + m.Post("/{provider}/acs", auth.SignInSAMLCallback) // TODO: Confirm if POST/GET + m.Get("/{provider}/metadata", auth.SAMLMetadata) + }) }) // ***** END: User ***** diff --git a/services/auth/source/saml/assert_interface_test.go b/services/auth/source/saml/assert_interface_test.go new file mode 100644 index 0000000000000..77cf185ece4cc --- /dev/null +++ b/services/auth/source/saml/assert_interface_test.go @@ -0,0 +1,19 @@ +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{} diff --git a/services/auth/source/saml/init.go b/services/auth/source/saml/init.go new file mode 100644 index 0000000000000..6440d1d2e9d41 --- /dev/null +++ b/services/auth/source/saml/init.go @@ -0,0 +1,25 @@ +package saml + +import ( + "sync" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/log" +) + +var samlRWMutex = sync.RWMutex{} + +func Init() error { + loginSources, _ := auth.GetActiveSAMLProviderLoginSources() + 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 +} diff --git a/services/auth/source/saml/name_id_format.go b/services/auth/source/saml/name_id_format.go new file mode 100644 index 0000000000000..38a8163cdc3c7 --- /dev/null +++ b/services/auth/source/saml/name_id_format.go @@ -0,0 +1,35 @@ +package saml + +type NameIDFormat int + +const ( + SAML11Email NameIDFormat = iota + 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) +} diff --git a/services/auth/source/saml/providers.go b/services/auth/source/saml/providers.go new file mode 100644 index 0000000000000..37c88972421ff --- /dev/null +++ b/services/auth/source/saml/providers.go @@ -0,0 +1,124 @@ +package saml + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "code.gitea.io/gitea/modules/httplib" + "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" +) + +// Providers is list of known/available providers. +type Providers map[string]Source + +var providers = Providers{} + +// used to create different types of goth providers +func createProvider(ctx context.Context, source *Source) (*Source, error) { + source.CallbackURL = setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/acs" + + idpMetadata, err := readIdentityProviderMetadata(ctx, source) + if err != nil { + return source, 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 source, err + } + + certStore := dsig.MemoryX509CertificateStore{ + Roots: []*x509.Certificate{}, + } + + for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors { + for idx, xcert := range kd.KeyInfo.X509Data.X509Certificates { + if xcert.Data == "" { + return source, fmt.Errorf("metadata certificate(%d) must not be empty", idx) + } + certData, err := base64.StdEncoding.DecodeString(xcert.Data) + if err != nil { + return source, err + } + + idpCert, err := x509.ParseCertificate(certData) + if err != nil { + return source, 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 nil, err + } + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, err + } + keyStore = dsig.TLSCertKeyStore(keyPair) + } else { + keyStore = dsig.RandomKeyStoreForTest() + } + + 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, + SignAuthnRequests: !source.InsecureSkipAssertionSignatureValidation, + NameIdFormat: source.NameIDFormat.String(), + IDPCertificateStore: &certStore, + SPKeyStore: keyStore, + ServiceProviderIssuer: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", + } + + return source, err +} + +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 +} diff --git a/services/auth/source/saml/source.go b/services/auth/source/saml/source.go new file mode 100644 index 0000000000000..3a2da635e0709 --- /dev/null +++ b/services/auth/source/saml/source.go @@ -0,0 +1,61 @@ +package saml + +import ( + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" + + saml2 "github.com/russellhaering/gosaml2" +) + +// _________ _____ _____ .____ +// / _____/ / _ \ / \ | | +// \_____ \ / /_\ \ / \ / \| | +// / \/ | \/ Y \ |___ +///_______ /\____|__ /\____|__ /_______ \ +// \/ \/ \/ \/ + +// 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 `` or ``. 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 ".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 + // SignRequests description: Sign AuthnRequests and LogoutRequests sent to the Identity Provider using the Service Provider's private key (`serviceProviderPrivateKey`). It defaults to true if the `serviceProviderPrivateKey` and `serviceProviderCertificate` are set, and false otherwise. + SignRequests bool + + CallbackURL string + + // reference to the authSource + authSource *auth.Source + + samlSP *saml2.SAMLServiceProvider +} + +// FromDB fills up a SAML from serialized format. +func (source *Source) FromDB(bs []byte) error { + return json.UnmarshalHandleDoubleEncode(bs, &source) +} + +// 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{}) +} diff --git a/services/auth/source/saml/source_authenticate.go b/services/auth/source/saml/source_authenticate.go new file mode 100644 index 0000000000000..8edd5cb3894ce --- /dev/null +++ b/services/auth/source/saml/source_authenticate.go @@ -0,0 +1,11 @@ +package saml + +import ( + 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(user *user_model.User, login, password string) (*user_model.User, error) { + return db.Authenticate(user, login, password) +} diff --git a/services/auth/source/saml/source_callout.go b/services/auth/source/saml/source_callout.go new file mode 100644 index 0000000000000..0fa464f5f1332 --- /dev/null +++ b/services/auth/source/saml/source_callout.go @@ -0,0 +1,31 @@ +package saml + +import ( + "fmt" + "net/http" +) + +// 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) (error, error) { + samlRWMutex.RLock() + defer samlRWMutex.RUnlock() + + // TODO: complete + return nil, nil +} diff --git a/services/auth/source/saml/source_metadata.go b/services/auth/source/saml/source_metadata.go new file mode 100644 index 0000000000000..57c4fc82e72e5 --- /dev/null +++ b/services/auth/source/saml/source_metadata.go @@ -0,0 +1,29 @@ +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.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + + response.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8") + _, _ = response.Write(buf) + return nil +} diff --git a/services/auth/source/saml/source_register.go b/services/auth/source/saml/source_register.go new file mode 100644 index 0000000000000..7ee390259ce2f --- /dev/null +++ b/services/auth/source/saml/source_register.go @@ -0,0 +1,23 @@ +package saml + +import "context" + +// RegisterSource causes an OAuth2 configuration to be registered +func (source *Source) RegisterSource() error { + samlRWMutex.Lock() + defer samlRWMutex.Unlock() + var err error + source, err = createProvider(context.Background(), source) + if err == nil { + providers[source.authSource.Name] = *source + } + return err +} + +// 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 +} diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 25acbbb99e877..5db6f3fafa02b 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -15,7 +15,7 @@ import ( // AuthenticationForm form for authentication type AuthenticationForm struct { ID int64 - Type int `binding:"Range(2,7)"` + Type int `binding:"Range(2,9)"` Name string `binding:"Required;MaxSize(30)"` Host string Port int @@ -82,6 +82,15 @@ type AuthenticationForm struct { SSPIDefaultLanguage string GroupTeamMap string `binding:"ValidGroupTeamMap"` GroupTeamMapRemoval bool + + // SAML Settings + NameIDFormat int + IdentityProviderMetadata string + IdentityProviderMetadataURL string + InsecureSkipAssertionSignatureValidation bool + ServiceProviderCertificate string + ServiceProviderPrivateKey string + SignRequests bool } // Validate validates fields diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index af9d4c4bc5024..df1127241bbc3 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -368,6 +368,56 @@ {{end}} + + {{if .Source.IsSAML}} + {{ $cfg:=.Source.Cfg }} +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ {{end}} + {{if .Source.IsSSPI}} {{$cfg:=.Source.Cfg}} diff --git a/templates/admin/auth/new.tmpl b/templates/admin/auth/new.tmpl index 5d9a9083c5c87..f0a007c68fcf3 100644 --- a/templates/admin/auth/new.tmpl +++ b/templates/admin/auth/new.tmpl @@ -53,6 +53,9 @@ {{template "admin/auth/source/sspi" .}} + ++ {{ template "admin/auth/source/saml" . }} +
diff --git a/templates/admin/auth/source/saml.tmpl b/templates/admin/auth/source/saml.tmpl new file mode 100644 index 0000000000000..f29852abf6603 --- /dev/null +++ b/templates/admin/auth/source/saml.tmpl @@ -0,0 +1,49 @@ +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js index 84fd35e081046..0afae704f3950 100644 --- a/web_src/js/features/admin/common.js +++ b/web_src/js/features/admin/common.js @@ -104,9 +104,9 @@ export function initAdminCommon() { // New authentication if ($('.admin.new.authentication').length > 0) { $('#auth_type').on('change', function () { - hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi')); + hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi, .saml')); - $('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]').removeAttr('required'); + $('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required], .saml input[required]').removeAttr('required'); $('.binddnrequired').removeClass('required'); const authType = $(this).val(); From 83975b09f15f204d6637cb8cb97fb6f02efebcae Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Thu, 8 Jun 2023 23:33:32 -0400 Subject: [PATCH 02/84] make vendor & make fmt --- go.mod | 5 +++++ go.sum | 14 ++++++++++++++ models/auth/saml.go | 3 +-- templates/admin/auth/edit.tmpl | 2 +- templates/admin/auth/new.tmpl | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index e92c08836e23b..0053831eafaa3 100644 --- a/go.mod +++ b/go.mod @@ -144,6 +144,7 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.7.0 // indirect github.com/blevesearch/bleve_index_api v1.0.5 // indirect @@ -215,6 +216,7 @@ require ( github.com/imdario/mergo v0.3.15 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jessevdk/go-flags v1.5.0 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect @@ -224,6 +226,7 @@ require ( github.com/magiconair/properties v1.8.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/markbates/going v1.0.0 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect @@ -256,6 +259,8 @@ require ( github.com/robfig/cron v1.2.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rs/xid v1.5.0 // indirect + github.com/russellhaering/gosaml2 v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect diff --git a/go.sum b/go.sum index 0a3e409690e3b..fe8f5800e1d45 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -752,6 +754,9 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -799,6 +804,7 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -844,6 +850,8 @@ github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsI github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -1068,6 +1076,8 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -1077,6 +1087,10 @@ github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= +github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= diff --git a/models/auth/saml.go b/models/auth/saml.go index e0dca70ec7d1b..23ec562cf5099 100644 --- a/models/auth/saml.go +++ b/models/auth/saml.go @@ -1,9 +1,8 @@ package auth import ( - "code.gitea.io/gitea/models/db" - auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" ) // GetActiveSAMLProviderLoginSources returns all actived LoginSAML sources diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index df1127241bbc3..34bde93fd08d9 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -370,7 +370,7 @@ {{if .Source.IsSAML}} - {{ $cfg:=.Source.Cfg }} + {{$cfg:=.Source.Cfg}}