diff --git a/api4/saml.go b/api4/saml.go index cb9f24fbfc..a25150b787 100644 --- a/api4/saml.go +++ b/api4/saml.go @@ -4,6 +4,8 @@ package api4 import ( + "io/ioutil" + "mime" "mime/multipart" "net/http" @@ -22,6 +24,8 @@ func (api *API) InitSaml() { api.BaseRoutes.SAML.Handle("/certificate/idp", api.ApiSessionRequired(removeSamlIdpCertificate)).Methods("DELETE") api.BaseRoutes.SAML.Handle("/certificate/status", api.ApiSessionRequired(getSamlCertificateStatus)).Methods("GET") + + api.BaseRoutes.SAML.Handle("/metadatafromidp", api.ApiHandler(getSamlMetadataFromIdp)).Methods("POST") } func getSamlMetadata(c *Context, w http.ResponseWriter, r *http.Request) { @@ -100,16 +104,44 @@ func addSamlIdpCertificate(c *Context, w http.ResponseWriter, r *http.Request) { return } - fileData, err := parseSamlCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize) + v := r.Header.Get("Content-Type") + if v == "" { + c.Err = model.NewAppError("addSamlIdpCertificate", "api.admin.saml.set_certificate_from_metadata.missing_content_type.app_error", nil, "", http.StatusBadRequest) + return + } + d, _, err := mime.ParseMediaType(v) if err != nil { - c.Err = err + c.Err = model.NewAppError("addSamlIdpCertificate", "api.admin.saml.set_certificate_from_metadata.invalid_content_type.app_error", nil, err.Error(), http.StatusBadRequest) return } - if err := c.App.AddSamlIdpCertificate(fileData); err != nil { - c.Err = err + if d == "application/x-pem-file" { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError("addSamlIdpCertificate", "api.admin.saml.set_certificate_from_metadata.invalid_body.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + + if err := c.App.SetSamlIdpCertificateFromMetadata(body); err != nil { + c.Err = err + return + } + } else if d == "multipart/form-data" { + fileData, err := parseSamlCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize) + if err != nil { + c.Err = err + return + } + + if err := c.App.AddSamlIdpCertificate(fileData); err != nil { + c.Err = err + return + } + } else { + c.Err = model.NewAppError("addSamlIdpCertificate", "api.admin.saml.set_certificate_from_metadata.invalid_content_type.app_error", nil, "", http.StatusBadRequest) return } + ReturnStatusOK(w) } @@ -164,3 +196,25 @@ func getSamlCertificateStatus(c *Context, w http.ResponseWriter, r *http.Request status := c.App.GetSamlCertificateStatus() w.Write([]byte(status.ToJson())) } + +func getSamlMetadataFromIdp(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + props := model.MapFromJson(r.Body) + url := props["saml_metadata_url"] + if url == "" { + c.SetInvalidParam("saml_metadata_url") + return + } + + metadata, err := c.App.GetSamlMetadataFromIdp(url) + if err != nil { + c.Err = model.NewAppError("getSamlMetadataFromIdp", "api.admin.saml.failure_get_metadata_from_idp.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + + w.Write([]byte(metadata.ToJson())) +} diff --git a/api4/user_test.go b/api4/user_test.go index 4a41afaded..025c896c43 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -4282,6 +4282,7 @@ func TestLoginErrorMessage(t *testing.T) { *cfg.SamlSettings.Encrypt = false *cfg.SamlSettings.IdpUrl = "https://localhost/adfs/ls" *cfg.SamlSettings.IdpDescriptorUrl = "https://localhost/adfs/services/trust" + *cfg.SamlSettings.IdpMetadataUrl = "https://localhost/adfs/metadata" *cfg.SamlSettings.AssertionConsumerServiceURL = "https://localhost/login/sso/saml" *cfg.SamlSettings.IdpCertificateFile = app.SamlIdpCertificateName *cfg.SamlSettings.PrivateKeyFile = app.SamlPrivateKeyName diff --git a/app/saml.go b/app/saml.go index 5b725c02ef..5f2245de7c 100644 --- a/app/saml.go +++ b/app/saml.go @@ -4,9 +4,14 @@ package app import ( + "crypto/x509" + "encoding/pem" + "encoding/xml" + "fmt" "io/ioutil" "mime/multipart" "net/http" + "strings" "github.com/mattermost/mattermost-server/v5/model" ) @@ -172,3 +177,108 @@ func (a *App) GetSamlCertificateStatus() *model.SamlCertificateStatus { return status } + +func (a *App) GetSamlMetadataFromIdp(idpMetadataUrl string) (*model.SamlMetadataResponse, *model.AppError) { + if a.Saml == nil { + err := model.NewAppError("GetSamlMetadataFromIdp", "api.admin.saml.not_available.app_error", nil, "", http.StatusNotImplemented) + return nil, err + } + + if !strings.HasPrefix(idpMetadataUrl, "http://") && !strings.HasPrefix(idpMetadataUrl, "https://") { + idpMetadataUrl = "https://" + idpMetadataUrl + } + + idpMetadataRaw, err := a.FetchSamlMetadataFromIdp(idpMetadataUrl) + if err != nil { + return nil, err + } + + data, err := a.BuildSamlMetadataObject(idpMetadataRaw) + if err != nil { + return nil, err + } + + return data, nil +} + +func (a *App) FetchSamlMetadataFromIdp(url string) ([]byte, *model.AppError) { + resp, err := a.HTTPService.MakeClient(false).Get(url) + if err != nil { + return nil, model.NewAppError("FetchSamlMetadataFromIdp", "app.admin.saml.invalid_response_from_idp.app_error", nil, err.Error(), http.StatusBadRequest) + } + + if resp.StatusCode != http.StatusOK { + return nil, model.NewAppError("FetchSamlMetadataFromIdp", "app.admin.saml.invalid_response_from_idp.app_error", nil, fmt.Sprintf("status_code=%d", resp.StatusCode), http.StatusBadRequest) + } + defer resp.Body.Close() + + bodyXML, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, model.NewAppError("FetchSamlMetadataFromIdp", "app.admin.saml.failure_read_response_body_from_idp.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return bodyXML, nil +} + +func (a *App) BuildSamlMetadataObject(idpMetadata []byte) (*model.SamlMetadataResponse, *model.AppError) { + entityDescriptor := model.EntityDescriptor{} + err := xml.Unmarshal(idpMetadata, &entityDescriptor) + if err != nil { + return nil, model.NewAppError("BuildSamlMetadataObject", "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + data := &model.SamlMetadataResponse{} + data.IdpDescriptorUrl = entityDescriptor.EntityID + + if entityDescriptor.IDPSSODescriptors == nil || len(entityDescriptor.IDPSSODescriptors) == 0 { + err := model.NewAppError("BuildSamlMetadataObject", "api.admin.saml.invalid_xml_missing_idpssodescriptors.app_error", nil, "", http.StatusInternalServerError) + return nil, err + } + + idpSSODescriptor := entityDescriptor.IDPSSODescriptors[0] + if idpSSODescriptor.SingleSignOnServices == nil || len(idpSSODescriptor.SingleSignOnServices) == 0 { + err := model.NewAppError("BuildSamlMetadataObject", "api.admin.saml.invalid_xml_missing_ssoservices.app_error", nil, "", http.StatusInternalServerError) + return nil, err + } + + data.IdpUrl = idpSSODescriptor.SingleSignOnServices[0].Location + if idpSSODescriptor.SSODescriptor.RoleDescriptor.KeyDescriptors == nil || len(idpSSODescriptor.SSODescriptor.RoleDescriptor.KeyDescriptors) == 0 { + err := model.NewAppError("BuildSamlMetadataObject", "api.admin.saml.invalid_xml_missing_keydescriptor.app_error", nil, "", http.StatusInternalServerError) + return nil, err + } + keyDescriptor := idpSSODescriptor.SSODescriptor.RoleDescriptor.KeyDescriptors[0] + data.IdpPublicCertificate = keyDescriptor.KeyInfo.X509Data.X509Certificate.Cert + + return data, nil +} + +func (a *App) SetSamlIdpCertificateFromMetadata(data []byte) *model.AppError { + const certPrefix = "-----BEGIN CERTIFICATE-----\n" + const certSuffix = "\n-----END CERTIFICATE-----" + fixedCertTxt := certPrefix + string(data) + certSuffix + + block, _ := pem.Decode([]byte(fixedCertTxt)) + if _, e := x509.ParseCertificate(block.Bytes); e != nil { + return model.NewAppError("SetSamlIdpCertificateFromMetadata", "api.admin.saml.failure_parse_idp_certificate.app_error", nil, e.Error(), http.StatusInternalServerError) + } + + data = pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: block.Bytes, + }) + + if err := a.Srv.configStore.SetFile(SamlIdpCertificateName, data); err != nil { + return model.NewAppError("SetSamlIdpCertificateFromMetadata", "api.admin.saml.failure_save_idp_certificate_file.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + cfg := a.Config().Clone() + *cfg.SamlSettings.IdpCertificateFile = SamlIdpCertificateName + + if err := cfg.IsValid(); err != nil { + return err + } + + a.UpdateConfig(func(dest *model.Config) { *dest = *cfg }) + + return nil +} diff --git a/i18n/en.json b/i18n/en.json index 7f4eec7b1e..24fa6bc98f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -83,6 +83,30 @@ "id": "api.admin.remove_certificate.delete.app_error", "translation": "An error occurred while deleting the certificate." }, + { + "id": "api.admin.saml.failure_get_metadata_from_idp.app_error", + "translation": "Failed to obtain metadata from Identity Provider URL." + }, + { + "id": "api.admin.saml.failure_parse_idp_certificate.app_error", + "translation": "Failure encountered while parsing the metadata information received from the Identity Provider to a certificate." + }, + { + "id": "api.admin.saml.failure_save_idp_certificate_file.app_error", + "translation": "Could not save certificate file." + }, + { + "id": "api.admin.saml.invalid_xml_missing_idpssodescriptors.app_error", + "translation": "Missing Identity Provider SSO Descriptors node in the XML." + }, + { + "id": "api.admin.saml.invalid_xml_missing_keydescriptor.app_error", + "translation": "Missing Identity Provider Key Descriptors node in the XML." + }, + { + "id": "api.admin.saml.invalid_xml_missing_ssoservices.app_error", + "translation": "Missing Identity Provider SSO Services node in the XML." + }, { "id": "api.admin.saml.metadata.app_error", "translation": "An error occurred while building Service Provider Metadata." @@ -91,6 +115,18 @@ "id": "api.admin.saml.not_available.app_error", "translation": "SAML 2.0 is not configured or supported on this server." }, + { + "id": "api.admin.saml.set_certificate_from_metadata.invalid_body.app_error", + "translation": "Invalid certificate text." + }, + { + "id": "api.admin.saml.set_certificate_from_metadata.invalid_content_type.app_error", + "translation": "Invalid content type." + }, + { + "id": "api.admin.saml.set_certificate_from_metadata.missing_content_type.app_error", + "translation": "Missing content type." + }, { "id": "api.admin.test_email.body", "translation": "It appears your Mattermost email is setup correctly!" @@ -2838,6 +2874,18 @@ "id": "api.websocket_handler.server_busy.app_error", "translation": "Server is busy, non-critical services are temporarily unavailable" }, + { + "id": "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", + "translation": "Could not decode the XML metadata information received from the Identity Provider." + }, + { + "id": "app.admin.saml.failure_read_response_body_from_idp.app_error", + "translation": "Failure encountered when reading the response payload received from the Identity Provider." + }, + { + "id": "app.admin.saml.invalid_response_from_idp.app_error", + "translation": "Could not read the response received from the Identity Provider." + }, { "id": "app.admin.test_email.failure", "translation": "Connection unsuccessful: {{.Error}}" diff --git a/model/client4.go b/model/client4.go index 4bf7150f0c..9b2fdbc257 100644 --- a/model/client4.go +++ b/model/client4.go @@ -3463,6 +3463,18 @@ func (c *Client4) GetSamlCertificateStatus() (*SamlCertificateStatus, *Response) return SamlCertificateStatusFromJson(r.Body), BuildResponse(r) } +func (c *Client4) GetSamlMetadataFromIdp(samlMetadataURL string) (*SamlMetadataResponse, *Response) { + requestBody := make(map[string]string) + requestBody["saml_metadata_url"] = samlMetadataURL + r, err := c.DoApiPost(c.GetSamlRoute()+"/metadatafromidp", MapToJson(requestBody)) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + + defer closeBody(r) + return SamlMetadataResponseFromJson(r.Body), BuildResponse(r) +} + // Compliance Section // CreateComplianceReport creates an incoming webhook for a channel. diff --git a/model/config.go b/model/config.go index f23c9ba653..5a3faad9d8 100644 --- a/model/config.go +++ b/model/config.go @@ -1913,6 +1913,7 @@ type SamlSettings struct { IdpUrl *string IdpDescriptorUrl *string + IdpMetadataUrl *string AssertionConsumerServiceURL *string SignatureAlgorithm *string @@ -1984,6 +1985,10 @@ func (s *SamlSettings) SetDefaults() { s.IdpDescriptorUrl = NewString("") } + if s.IdpMetadataUrl == nil { + s.IdpMetadataUrl = NewString("") + } + if s.IdpCertificateFile == nil { s.IdpCertificateFile = NewString("") } diff --git a/model/config_test.go b/model/config_test.go index 21026a270e..47cc364bf4 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -157,6 +157,7 @@ func TestConfigIsValidFakeAlgorithm(t *testing.T) { *c1.SamlSettings.IdpUrl = "http://test.url.com" *c1.SamlSettings.IdpDescriptorUrl = "http://test.url.com" + *c1.SamlSettings.IdpMetadataUrl = "http://test.url.com" *c1.SamlSettings.IdpCertificateFile = "certificatefile" *c1.SamlSettings.EmailAttribute = "Email" *c1.SamlSettings.UsernameAttribute = "Username" diff --git a/model/saml.go b/model/saml.go index efc3cbac09..2f289ffd51 100644 --- a/model/saml.go +++ b/model/saml.go @@ -5,7 +5,9 @@ package model import ( "encoding/json" + "encoding/xml" "io" + "time" ) const ( @@ -25,6 +27,153 @@ type SamlCertificateStatus struct { PublicCertificateFile bool `json:"public_certificate_file"` } +type SamlMetadataResponse struct { + IdpDescriptorUrl string `json:"idp_descriptor_url"` + IdpUrl string `json:"idp_url"` + IdpPublicCertificate string `json:"idp_public_certificate"` +} + +type NameIDFormat struct { + XMLName xml.Name + Format string `xml:",attr,omitempty"` + Value string `xml:",innerxml"` +} + +type NameID struct { + NameQualifier string `xml:",attr"` + SPNameQualifier string `xml:",attr"` + Format string `xml:",attr,omitempty"` + SPProvidedID string `xml:",attr"` + Value string `xml:",chardata"` +} + +type AttributeValue struct { + Type string `xml:"http://www.w3.org/2001/XMLSchema-instance type,attr"` + Value string `xml:",chardata"` + NameID *NameID +} + +type Attribute struct { + XMLName xml.Name + FriendlyName string `xml:",attr"` + Name string `xml:",attr"` + NameFormat string `xml:",attr"` + Values []AttributeValue `xml:"AttributeValue"` +} + +type Endpoint struct { + XMLName xml.Name + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` + ResponseLocation string `xml:"ResponseLocation,attr,omitempty"` +} + +type IndexedEndpoint struct { + XMLName xml.Name + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` + ResponseLocation *string `xml:"ResponseLocation,attr,omitempty"` + Index int `xml:"index,attr"` + IsDefault *bool `xml:"isDefault,attr"` +} + +type IDPSSODescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"` + SSODescriptor + WantAuthnRequestsSigned *bool `xml:",attr"` + + SingleSignOnServices []Endpoint `xml:"SingleSignOnService"` + NameIDMappingServices []Endpoint `xml:"NameIDMappingService"` + AssertionIDRequestServices []Endpoint `xml:"AssertionIDRequestService"` + AttributeProfiles []string `xml:"AttributeProfile"` + Attributes []Attribute `xml:"Attribute"` +} + +type SSODescriptor struct { + XMLName xml.Name + RoleDescriptor + ArtifactResolutionServices []IndexedEndpoint `xml:"ArtifactResolutionService"` + SingleLogoutServices []Endpoint `xml:"SingleLogoutService"` + ManageNameIDServices []Endpoint `xml:"ManageNameIDService"` + NameIDFormats []NameIDFormat `xml:"NameIDFormat"` +} + +type X509Certificate struct { + XMLName xml.Name + Cert string `xml:",innerxml"` +} + +type X509Data struct { + XMLName xml.Name + X509Certificate X509Certificate `xml:"X509Certificate"` +} + +type KeyInfo struct { + XMLName xml.Name + DS string `xml:"xmlns:ds,attr"` + X509Data X509Data `xml:"X509Data"` +} +type EncryptionMethod struct { + Algorithm string `xml:"Algorithm,attr"` +} + +type KeyDescriptor struct { + XMLName xml.Name + Use string `xml:"use,attr,omitempty"` + KeyInfo KeyInfo `xml:"http://www.w3.org/2000/09/xmldsig# KeyInfo,omitempty"` +} + +type RoleDescriptor struct { + XMLName xml.Name + ID string `xml:",attr,omitempty"` + ValidUntil time.Time `xml:"validUntil,attr,omitempty"` + CacheDuration time.Duration `xml:"cacheDuration,attr,omitempty"` + ProtocolSupportEnumeration string `xml:"protocolSupportEnumeration,attr"` + ErrorURL string `xml:"errorURL,attr,omitempty"` + KeyDescriptors []KeyDescriptor `xml:"KeyDescriptor,omitempty"` + Organization *Organization `xml:"Organization,omitempty"` + ContactPersons []ContactPerson `xml:"ContactPerson,omitempty"` +} + +type ContactPerson struct { + XMLName xml.Name + ContactType string `xml:"contactType,attr"` + Company string + GivenName string + SurName string + EmailAddresses []string `xml:"EmailAddress"` + TelephoneNumbers []string `xml:"TelephoneNumber"` +} + +type LocalizedName struct { + Lang string `xml:"xml lang,attr"` + Value string `xml:",chardata"` +} + +type LocalizedURI struct { + Lang string `xml:"xml lang,attr"` + Value string `xml:",chardata"` +} + +type Organization struct { + XMLName xml.Name + OrganizationNames []LocalizedName `xml:"OrganizationName"` + OrganizationDisplayNames []LocalizedName `xml:"OrganizationDisplayName"` + OrganizationURLs []LocalizedURI `xml:"OrganizationURL"` +} + +type EntityDescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"` + EntityID string `xml:"entityID,attr"` + ID string `xml:",attr,omitempty"` + ValidUntil time.Time `xml:"validUntil,attr,omitempty"` + CacheDuration time.Duration `xml:"cacheDuration,attr,omitempty"` + RoleDescriptors []RoleDescriptor `xml:"RoleDescriptor"` + IDPSSODescriptors []IDPSSODescriptor `xml:"IDPSSODescriptor"` + Organization Organization `xml:"Organization"` + ContactPerson ContactPerson `xml:"ContactPerson"` +} + func (s *SamlCertificateStatus) ToJson() string { b, _ := json.Marshal(s) return string(b) @@ -35,3 +184,14 @@ func SamlCertificateStatusFromJson(data io.Reader) *SamlCertificateStatus { json.NewDecoder(data).Decode(&status) return status } + +func (s *SamlMetadataResponse) ToJson() string { + b, _ := json.Marshal(s) + return string(b) +} + +func SamlMetadataResponseFromJson(data io.Reader) *SamlMetadataResponse { + var status *SamlMetadataResponse + json.NewDecoder(data).Decode(&status) + return status +} diff --git a/tests/test-config.json b/tests/test-config.json index 64738c872a..e5a40d3cf3 100644 --- a/tests/test-config.json +++ b/tests/test-config.json @@ -289,6 +289,7 @@ "Encrypt": true, "IdpUrl": "", "IdpDescriptorUrl": "", + "IdpMetadataUrl": "", "AssertionConsumerServiceURL": "", "ScopingIDPProviderId": "", "ScopingIDPName": "", @@ -401,4 +402,4 @@ "CustomUrlSchemes": [], "ExperimentalTimezone": false } -} +} \ No newline at end of file