diff --git a/models/auth/source.go b/models/auth/source.go index 0a904b777239..22abf6556f28 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 @@ -148,43 +150,48 @@ func (source *Source) TypeName() string { return Names[source.Type] } -// IsLDAP returns true of this source is of the LDAP type. +// IsLDAP returns true if this source is of the LDAP type. func (source *Source) IsLDAP() bool { return source.Type == LDAP } -// IsDLDAP returns true of this source is of the DLDAP type. +// IsDLDAP returns true if this source is of the DLDAP type. func (source *Source) IsDLDAP() bool { return source.Type == DLDAP } -// IsSMTP returns true of this source is of the SMTP type. +// IsSMTP returns true if this source is of the SMTP type. func (source *Source) IsSMTP() bool { return source.Type == SMTP } -// IsPAM returns true of this source is of the PAM type. +// IsPAM returns true if this source is of the PAM type. func (source *Source) IsPAM() bool { return source.Type == PAM } -// IsOAuth2 returns true of this source is of the OAuth2 type. +// IsOAuth2 returns true if this source is of the OAuth2 type. func (source *Source) IsOAuth2() bool { return source.Type == OAuth2 } -// IsSSPI returns true of this source is of the SSPI type. +// IsSSPI returns true if this source is of the SSPI type. func (source *Source) IsSSPI() bool { return source.Type == SSPI } -// HasTLS returns true of this source supports TLS. +// IsSAML returns true if this source is of the SAML type. +func (source *Source) IsSAML() bool { + return source.Type == SAML +} + +// HasTLS returns true if this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) return ok && hasTLSer.HasTLS() } -// UseTLS returns true of this source is configured to use TLS. +// UseTLS returns true if this source is configured to use TLS. func (source *Source) UseTLS() bool { useTLSer, ok := source.Cfg.(UseTLSer) return ok && useTLSer.UseTLS() diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 195252c47d17..b03c900be063 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2895,6 +2895,11 @@ auths.sspi_separator_replacement = Separator to use instead of \, / and @ auths.sspi_separator_replacement_helper = The character to use to replace the separators of down-level logon names (eg. the \ in "DOMAIN\user") and user principal names (eg. the @ in "user@example.org"). auths.sspi_default_language = Default user language auths.sspi_default_language_helper = Default language for users automatically created by SSPI auth method. Leave empty if you prefer language to be automatically detected. +auths.saml.issuer = IdP Issuer ID +auths.saml.login = IdP Login URL +auths.saml.logout = IdP Logout URL +auths.saml.certificate = IdP Certificate +auths.saml.certificate_error = %s is not a valid Base64-encoded certificate. auths.tips = Tips auths.tips.oauth2.general = OAuth2 Authentication auths.tips.oauth2.general.tip = When registering a new OAuth2 authentication, the callback/redirect URL should be: /user/oauth2//callback diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index b6ea3ff40300..8575c1e9c873 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -4,6 +4,8 @@ package admin import ( + "crypto/x509" + "encoding/base64" "errors" "fmt" "net/http" @@ -24,6 +26,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 +74,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}) @@ -231,6 +235,56 @@ func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi }, nil } +// parseSAMLConfig verifies that required fields are supplied and that fields are properly typed (URLs are URLs, certificate is certificate) +func parseSAMLConfig(ctx *context.Context, form forms.AuthenticationForm) (*saml.Source, error) { + if util.IsEmptyString(form.SAMLIssuer) { + ctx.Data["Err_SAMLIssuer"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.issuer") + ctx.Tr("form.require_error")) + } + + if util.IsEmptyString(form.SAMLLogin) { + ctx.Data["Err_SAMLLogin"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.login") + ctx.Tr("form.require_error")) + } + _, err := url.ParseRequestURI(form.SAMLLogin) + if err != nil { + ctx.Data["Err_SAMLLogin"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.login") + " " + ctx.Tr("form.url_error", form.SAMLLogin)) + } + + if !util.IsEmptyString(form.SAMLLogout) { + _, err := url.ParseRequestURI(form.SAMLLogout) + if err != nil { + ctx.Data["Err_SAMLLogout"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.logout") + " " + ctx.Tr("form.url_error", form.SAMLLogout)) + } + } + + if util.IsEmptyString(form.SAMLCertificate) { + ctx.Data["Err_SAMLCertificate"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.certificate") + ctx.Tr("form.require_error")) + } + certData, err := base64.StdEncoding.DecodeString(form.SAMLCertificate) + if err != nil { + ctx.Data["Err_SAMLCertificate"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.certificate_error", ctx.Tr("admin.auths.saml.certificate"))) + } + _, err = x509.ParseCertificate(certData) + if err != nil { + ctx.Data["Err_SAMLCertificate"] = true + return nil, errors.New(ctx.Tr("admin.auths.saml.certificate_error", ctx.Tr("admin.auths.saml.certificate"))) + } + + return &saml.Source{ + IdPIssuer: form.SAMLIssuer, + IdPLogin: form.SAMLLogin, + IdPLogout: form.SAMLLogout, + IdPCertificate: form.SAMLCertificate, + + SkipLocalTwoFA: form.SkipLocalTwoFA, + }, nil +} + // NewAuthSourcePost response for adding an auth source func NewAuthSourcePost(ctx *context.Context) { form := *web.GetForm(ctx).(*forms.AuthenticationForm) @@ -290,6 +344,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 @@ -412,6 +473,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/services/auth/source/saml/source.go b/services/auth/source/saml/source.go new file mode 100644 index 000000000000..d4ec7922c1d2 --- /dev/null +++ b/services/auth/source/saml/source.go @@ -0,0 +1,36 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package saml + +import ( + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" +) + +// Source stores the configuration for a SAML authentication source +type Source struct { + IdPIssuer string + IdPLogin string + IdPLogout string + IdPCertificate string + + SkipLocalTwoFA bool `json:",omitempty"` + + // reference to the authSource + authSource *auth.Source +} + +// FromDB fills up an OAuth2Config from serialized format. +func (source *Source) FromDB(bs []byte) error { + return json.UnmarshalHandleDoubleEncode(bs, &source) +} + +// ToDB exports an SMTPConfig to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + return json.Marshal(source) +} + +func init() { + auth.RegisterTypeConfig(auth.SAML, &Source{}) +} diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 25acbbb99e87..77d766f5cbc5 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,8)"` Name string `binding:"Required;MaxSize(30)"` Host string Port int @@ -80,6 +80,10 @@ type AuthenticationForm struct { SSPIStripDomainNames bool SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"` SSPIDefaultLanguage string + SAMLIssuer string + SAMLLogin string + SAMLLogout string + SAMLCertificate string GroupTeamMap string `binding:"ValidGroupTeamMap"` GroupTeamMapRemoval bool } diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index af9d4c4bc502..d0eb6659a774 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -413,6 +413,35 @@

{{.locale.Tr "admin.auths.sspi_default_language_helper"}}

{{end}} + + + {{if .Source.IsSAML}} + {{$cfg:=.Source.Cfg}} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+
+
+ {{end}} + {{if .Source.IsLDAP}}
diff --git a/templates/admin/auth/new.tmpl b/templates/admin/auth/new.tmpl index 5d9a9083c5c8..c5d1c7254e43 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 000000000000..bca8785cfb1f --- /dev/null +++ b/templates/admin/auth/source/saml.tmpl @@ -0,0 +1,25 @@ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+
+
+
diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js index 84fd35e08104..d276301ce4b2 100644 --- a/web_src/js/features/admin/common.js +++ b/web_src/js/features/admin/common.js @@ -104,7 +104,7 @@ 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'); $('.binddnrequired').removeClass('required'); @@ -138,6 +138,10 @@ export function initAdminCommon() { showElem($('.sspi')); $('.sspi div.required input').attr('required', 'required'); break; + case '8': // SAML + showElem($('.saml')); + $('.saml div.required input').attr('required', 'required'); + break; } if (authType === '2' || authType === '5') { onSecurityProtocolChange();