diff options
39 files changed, 1983 insertions, 16 deletions
diff --git a/api/admin.go b/api/admin.go index f0db5a4af..4d1528104 100644 --- a/api/admin.go +++ b/api/admin.go @@ -5,6 +5,7 @@ package api import ( "bufio" + "io" "io/ioutil" "net/http" "os" @@ -41,6 +42,9 @@ func InitAdmin() { BaseRoutes.Admin.Handle("/reset_mfa", ApiAdminSystemRequired(adminResetMfa)).Methods("POST") BaseRoutes.Admin.Handle("/reset_password", ApiAdminSystemRequired(adminResetPassword)).Methods("POST") BaseRoutes.Admin.Handle("/ldap_sync_now", ApiAdminSystemRequired(ldapSyncNow)).Methods("POST") + BaseRoutes.Admin.Handle("/saml_metadata", ApiAppHandler(samlMetadata)).Methods("GET") + BaseRoutes.Admin.Handle("/add_certificate", ApiAdminSystemRequired(addCertificate)).Methods("POST") + BaseRoutes.Admin.Handle("/remove_certificate", ApiAdminSystemRequired(removeCertificate)).Methods("POST") } func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { @@ -582,3 +586,76 @@ func ldapSyncNow(c *Context, w http.ResponseWriter, r *http.Request) { rdata["status"] = "ok" w.Write([]byte(model.MapToJson(rdata))) } + +func samlMetadata(c *Context, w http.ResponseWriter, r *http.Request) { + samlInterface := einterfaces.GetSamlInterface() + + if samlInterface == nil { + c.Err = model.NewLocAppError("loginWithSaml", "api.admin.saml.not_available.app_error", nil, "") + c.Err.StatusCode = http.StatusFound + return + } + + if result, err := samlInterface.GetMetadata(); err != nil { + c.Err = model.NewLocAppError("loginWithSaml", "api.admin.saml.metadata.app_error", nil, "err="+err.Message) + return + } else { + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Disposition", "attachment; filename=\"metadata.xml\"") + w.Write([]byte(result)) + } +} + +func addCertificate(c *Context, w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + m := r.MultipartForm + + fileArray, ok := m.File["certificate"] + if !ok { + c.Err = model.NewLocAppError("addCertificate", "api.admin.add_certificate.no_file.app_error", nil, "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + if len(fileArray) <= 0 { + c.Err = model.NewLocAppError("addCertificate", "api.admin.add_certificate.array.app_error", nil, "") + c.Err.StatusCode = http.StatusBadRequest + return + } + + fileData := fileArray[0] + + file, err := fileData.Open() + defer file.Close() + if err != nil { + c.Err = model.NewLocAppError("addCertificate", "api.admin.add_certificate.open.app_error", nil, err.Error()) + return + } + + out, err := os.Create(utils.FindDir("config") + fileData.Filename) + if err != nil { + c.Err = model.NewLocAppError("addCertificate", "api.admin.add_certificate.saving.app_error", nil, err.Error()) + return + } + defer out.Close() + + io.Copy(out, file) + ReturnStatusOK(w) +} + +func removeCertificate(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJson(r.Body) + + filename := props["filename"] + if err := os.Remove(utils.FindConfigFile(filename)); err != nil { + c.Err = model.NewLocAppError("removeCertificate", "api.admin.remove_certificate.delete.app_error", + map[string]interface{}{"Filename": filename}, err.Error()) + return + } + ReturnStatusOK(w) +} diff --git a/api/authentication.go b/api/authentication.go index 42a395253..8170f0a8e 100644 --- a/api/authentication.go +++ b/api/authentication.go @@ -9,6 +9,7 @@ import ( "github.com/mattermost/platform/utils" "net/http" + "strings" ) func checkPasswordAndAllCriteria(user *model.User, password string, mfaToken string) *model.AppError { @@ -145,7 +146,11 @@ func authenticateUser(user *model.User, password, mfaToken string) (*model.User, return ldapUser, nil } } else if user.AuthService != "" { - err := model.NewLocAppError("login", "api.user.login.use_auth_service.app_error", map[string]interface{}{"AuthService": user.AuthService}, "") + authService := user.AuthService + if authService == model.USER_AUTH_SERVICE_SAML || authService == model.USER_AUTH_SERVICE_LDAP { + authService = strings.ToUpper(authService) + } + err := model.NewLocAppError("login", "api.user.login.use_auth_service.app_error", map[string]interface{}{"AuthService": authService}, "") err.StatusCode = http.StatusBadRequest return user, err } else { diff --git a/api/context.go b/api/context.go index 1c0dae299..93ff83247 100644 --- a/api/context.go +++ b/api/context.go @@ -477,6 +477,11 @@ func RenderWebError(err *model.AppError, w http.ResponseWriter, r *http.Request) link := "/" linkMessage := T("api.templates.error.link") + status := http.StatusTemporaryRedirect + if err.StatusCode != http.StatusInternalServerError { + status = err.StatusCode + } + http.Redirect( w, r, @@ -485,7 +490,7 @@ func RenderWebError(err *model.AppError, w http.ResponseWriter, r *http.Request) "&details="+url.QueryEscape(details)+ "&link="+url.QueryEscape(link)+ "&linkmessage="+url.QueryEscape(linkMessage), - http.StatusTemporaryRedirect) + status) } func Handle404(w http.ResponseWriter, r *http.Request) { diff --git a/api/user.go b/api/user.go index 2ffda1bc5..47f20f6bf 100644 --- a/api/user.go +++ b/api/user.go @@ -5,6 +5,7 @@ package api import ( "bytes" + b64 "encoding/base64" "fmt" "hash/fnv" "html/template" @@ -71,6 +72,9 @@ func InitUser() { BaseRoutes.NeedUser.Handle("/sessions", ApiUserRequired(getSessions)).Methods("GET") BaseRoutes.NeedUser.Handle("/audits", ApiUserRequired(getAudits)).Methods("GET") BaseRoutes.NeedUser.Handle("/image", ApiUserRequiredTrustRequester(getProfileImage)).Methods("GET") + + BaseRoutes.Root.Handle("/login/sso/saml", AppHandlerIndependent(loginWithSaml)).Methods("GET") + BaseRoutes.Root.Handle("/login/sso/saml", AppHandlerIndependent(completeSaml)).Methods("POST") } func createUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -2005,12 +2009,16 @@ func emailToOAuth(c *Context, w http.ResponseWriter, r *http.Request) { stateProps["email"] = email m := map[string]string{} - if authUrl, err := GetAuthorizationCode(c, service, stateProps, ""); err != nil { - c.LogAuditWithUserId(user.Id, "fail - oauth issue") - c.Err = err - return + if service == model.USER_AUTH_SERVICE_SAML { + m["follow_link"] = c.GetSiteURL() + "/login/sso/saml?action=" + model.OAUTH_ACTION_EMAIL_TO_SSO + "&email=" + email } else { - m["follow_link"] = authUrl + if authUrl, err := GetAuthorizationCode(c, service, stateProps, ""); err != nil { + c.LogAuditWithUserId(user.Id, "fail - oauth issue") + c.Err = err + return + } else { + m["follow_link"] = authUrl + } } c.LogAuditWithUserId(user.Id, "success") @@ -2419,3 +2427,91 @@ func checkMfa(c *Context, w http.ResponseWriter, r *http.Request) { } w.Write([]byte(model.MapToJson(rdata))) } + +func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { + samlInterface := einterfaces.GetSamlInterface() + + if samlInterface == nil { + c.Err = model.NewLocAppError("loginWithSaml", "api.user.saml.not_available.app_error", nil, "") + c.Err.StatusCode = http.StatusFound + return + } + + teamId, err := getTeamIdFromQuery(r.URL.Query()) + if err != nil { + c.Err = err + return + } + action := r.URL.Query().Get("action") + relayState := "" + + if len(action) != 0 { + relayProps := map[string]string{} + relayProps["team_id"] = teamId + relayProps["action"] = action + if action == model.OAUTH_ACTION_EMAIL_TO_SSO { + relayProps["email"] = r.URL.Query().Get("email") + } + relayState = b64.StdEncoding.EncodeToString([]byte(model.MapToJson(relayProps))) + } + + if data, err := samlInterface.BuildRequest(relayState); err != nil { + c.Err = err + return + } else { + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + http.Redirect(w, r, data.URL, http.StatusFound) + } +} + +func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { + samlInterface := einterfaces.GetSamlInterface() + + if samlInterface == nil { + c.Err = model.NewLocAppError("completeSaml", "api.user.saml.not_available.app_error", nil, "") + c.Err.StatusCode = http.StatusFound + return + } + + //Validate that the user is with SAML and all that + encodedXML := r.FormValue("SAMLResponse") + relayState := r.FormValue("RelayState") + + relayProps := make(map[string]string) + if len(relayState) > 0 { + stateStr := "" + if b, err := b64.StdEncoding.DecodeString(relayState); err != nil { + c.Err = model.NewLocAppError("completeSaml", "api.user.authorize_oauth_user.invalid_state.app_error", nil, err.Error()) + c.Err.StatusCode = http.StatusFound + return + } else { + stateStr = string(b) + } + relayProps = model.MapFromJson(strings.NewReader(stateStr)) + } + + if user, err := samlInterface.DoLogin(encodedXML, relayProps); err != nil { + c.Err = err + c.Err.StatusCode = http.StatusFound + return + } else { + if err := checkUserAdditionalAuthenticationCriteria(user, ""); err != nil { + c.Err = err + c.Err.StatusCode = http.StatusFound + return + } + action := relayProps["action"] + switch action { + case model.OAUTH_ACTION_SIGNUP: + teamId := relayProps["team_id"] + go addDirectChannels(teamId, user) + break + case model.OAUTH_ACTION_EMAIL_TO_SSO: + RevokeAllSession(c, user.Id) + go sendSignInChangeEmail(c, user.Email, c.GetSiteURL(), strings.Title(model.USER_AUTH_SERVICE_SAML)+" SSO") + break + } + doLogin(c, w, r, user, "") + http.Redirect(w, r, GetProtocol(r)+"://"+r.Host, http.StatusFound) + } +} diff --git a/config/config.json b/config/config.json index fb325248d..ec021045f 100644 --- a/config/config.json +++ b/config/config.json @@ -166,5 +166,23 @@ "DefaultServerLocale": "en", "DefaultClientLocale": "en", "AvailableLocales": "" + }, + "SamlSettings": { + "Enable": false, + "Verify": false, + "Encrypt": false, + "IdpUrl": "", + "IdpDescriptorUrl": "", + "AssertionConsumerServiceURL": "", + "IdpCertificateFile": "", + "PublicCertificateFile": "", + "PrivateKeyFile": "", + "FirstNameAttribute": "", + "LastNameAttribute": "", + "EmailAttribute": "", + "UsernameAttribute": "", + "NicknameAttribute": "", + "LocaleAttribute": "", + "LoginButtonText": "" } }
\ No newline at end of file diff --git a/einterfaces/saml.go b/einterfaces/saml.go new file mode 100644 index 000000000..af2e815a5 --- /dev/null +++ b/einterfaces/saml.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/platform/model" +) + +type SamlInterface interface { + ConfigureSP() *model.AppError + BuildRequest(relayState string) (*model.SamlAuthRequest, *model.AppError) + DoLogin(encodedXML string, relayState map[string]string) (*model.User, *model.AppError) + GetMetadata() (string, *model.AppError) +} + +var theSamlInterface SamlInterface + +func RegisterSamlInterface(newInterface SamlInterface) { + theSamlInterface = newInterface +} + +func GetSamlInterface() SamlInterface { + return theSamlInterface +} diff --git a/glide.lock b/glide.lock index b1bde2847..7dfec68b4 100644 --- a/glide.lock +++ b/glide.lock @@ -42,6 +42,8 @@ imports: version: 9c19ed558d5df4da88e2ade9c8940d742aef0e7e - name: github.com/gorilla/websocket version: 1f512fc3f05332ba7117626cdfb4e07474e58e60 + - name: github.com/kardianos/osext + version: 29ae4ffbc9a6fe9fb2bc5029050ce6996ea1d3bc - name: github.com/lib/pq version: ee1442bda7bd1b6a84e913bdb421cb1874ec629d subpackages: diff --git a/glide.yaml b/glide.yaml index 81335ef38..b8879b2e5 100644 --- a/glide.yaml +++ b/glide.yaml @@ -39,3 +39,4 @@ import: - package: gopkg.in/throttled/throttled.v1 subpackages: - store +- package: github.com/kardianos/osext diff --git a/i18n/en.json b/i18n/en.json index ba78695c0..69b18adf3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -48,6 +48,22 @@ "translation": "September" }, { + "id": "api.admin.add_certificate.array.app_error", + "translation": "Empty array under 'certificate' in request" + }, + { + "id": "api.admin.add_certificate.no_file.app_error", + "translation": "No file under 'certificate' in request" + }, + { + "id": "api.admin.add_certificate.open.app_error", + "translation": "Could not open certificate file" + }, + { + "id": "api.admin.add_certificate.saving.app_error", + "translation": "Could not save certificate file" + }, + { "id": "api.admin.file_read_error", "translation": "Error reading log file" }, @@ -72,6 +88,14 @@ "translation": "Attempting to recycle the database connection" }, { + "id": "api.admin.remove_certificate.delete.app_error", + "translation": "An error occurred while deleting the certificate. Make sure the file config/{{.Filename}} exists." + }, + { + "id": "api.admin.saml.metadata.app_error", + "translation": "An error occurred while building Service Provider Metadata" + }, + { "id": "api.admin.test_email.body", "translation": "<br/><br/><br/>It appears your Mattermost email is setup correctly!" }, @@ -1100,6 +1124,10 @@ "translation": "session.user_id={{.SessionUserId}}, preference.user_id={{.PreferenceUserId}}" }, { + "id": "api.saml.save_certificate.app_error", + "translation": "Certificate did not save properly." + }, + { "id": "api.server.new_server.init.info", "translation": "Server is initializing..." }, @@ -1804,6 +1832,10 @@ "translation": "Trying to reset password for user on wrong team." }, { + "id": "api.user.saml.not_available.app_error", + "translation": "SAML is not configured or supported on this server." + }, + { "id": "api.user.send_email_change_email_and_forget.error", "translation": "Failed to send email change notification email successfully err=%v" }, @@ -2172,6 +2204,74 @@ "translation": "Error trying to authenticate MFA token" }, { + "id": "ent.saml.build_request.app_error", + "translation": "An error occurred while initiating the request to the Identity Provider. Please contact your System Administrator." + }, + { + "id": "ent.saml.build_request.encoding.app_error", + "translation": "An error occurred while encoding the request for the Identity Provider. Please contact your System Administrator." + }, + { + "id": "ent.saml.build_request.encoding_signed.app_error", + "translation": "An error occurred while encoding the signed request for the Identity Provider. Please contact your System Administrator." + }, + { + "id": "ent.saml.configure.app_error", + "translation": "An error occurred while configuring SAML Service Provider, err=%v" + }, + { + "id": "ent.saml.configure.encryption_not_enabled.app_error", + "translation": "SAML login was unsuccessful because encryption is not enabled. Please contact your System Administrator." + }, + { + "id": "ent.saml.configure.load_idp_cert.app_error", + "translation": "Identity Provider Public Certificate File was not found. Please contact your System Administrator." + }, + { + "id": "ent.saml.configure.load_private_key.app_error", + "translation": "SAML login was unsuccessful because the Service Provider Private Key was not found. Please contact your System Administrator." + }, + { + "id": "ent.saml.configure.load_public_cert.app_error", + "translation": "Service Provider Public Certificate File was not found. Please contact your System Administrator." + }, + { + "id": "ent.saml.configure.not_encrypted_response.app_error", + "translation": "SAML login was unsuccessful as the Identity Provider response is not encrypted. Please contact your System Administrator." + }, + { + "id": "ent.saml.do_login.decrypt.app_error", + "translation": "SAML login was unsuccessful because an error occurred while decrypting the response from the Identity Provider. Please contact your System Administrator." + }, + { + "id": "ent.saml.do_login.empty_response.app_error", + "translation": "We received an empty response from the Identity Provider" + }, + { + "id": "ent.saml.do_login.parse.app_error", + "translation": "An error occurred while parsing the response from the Identity Provider. Please contact your System Administrator." + }, + { + "id": "ent.saml.do_login.validate.app_error", + "translation": "An error occurred while validating the response from the Identity Provider. Please contact your System Administrator." + }, + { + "id": "ent.saml.license_disable.app_error", + "translation": "Your license does not support SAML authentication." + }, + { + "id": "ent.saml.metadata.app_error", + "translation": "An error occurred while building Service Provider Metadata." + }, + { + "id": "ent.saml.service_disable.app_error", + "translation": "SAML is not configured or supported on this server." + }, + { + "id": "ent.saml.update_saml_user.unable_error", + "translation": "Unable to update existing SAML user. Allowing login anyway. err=%v" + }, + { "id": "error.generic.link_message", "translation": "Back to Mattermost" }, @@ -2572,6 +2672,46 @@ "translation": "Invalid direct message restriction. Must be 'any', or 'team'" }, { + "id": "model.config.is_valid.saml_assertion_consumer_service_url.app_error", + "translation": "Service Provider Login URL must be a valid URL and start with http:// or https://." + }, + { + "id": "model.config.is_valid.saml_email_attribute.app_error", + "translation": "Invalid Email attribute. Must be set." + }, + { + "id": "model.config.is_valid.saml_first_name_attribute.app_error", + "translation": "Invalid First Name attribute. Must be set." + }, + { + "id": "model.config.is_valid.saml_idp_cert.app_error", + "translation": "Identity Provider Public Certificate missing. Did you forget to upload it?" + }, + { + "id": "model.config.is_valid.saml_idp_descriptor_url.app_error", + "translation": "Identity Provider Issuer URL must be a valid URL and start with http:// or https://." + }, + { + "id": "model.config.is_valid.saml_idp_url.app_error", + "translation": "SAML SSO URL must be a valid URL and start with http:// or https://." + }, + { + "id": "model.config.is_valid.saml_last_name_attribute.app_error", + "translation": "Invalid Last Name attribute. Must be set." + }, + { + "id": "model.config.is_valid.saml_private_key.app_error", + "translation": "Service Provider Private Key missing. Did you forget to upload it?" + }, + { + "id": "model.config.is_valid.saml_public_cert.app_error", + "translation": "Service Provider Public Certificate missing. Did you forget to upload it?" + }, + { + "id": "model.config.is_valid.saml_username_attribute.app_error", + "translation": "Invalid Username attribute. Must be set." + }, + { "id": "model.config.is_valid.sql_data_src.app_error", "translation": "Invalid data source for SQL settings. Must be set." }, @@ -3796,6 +3936,10 @@ "translation": "This account does not use LDAP authentication. Please sign in using email and password." }, { + "id": "store.sql_user.save.email_exists.saml_app_error", + "translation": "This account does not use SAML authentication. Please sign in using email and password." + }, + { "id": "store.sql_user.save.existing.app_error", "translation": "Must call update for exisiting user" }, @@ -3816,6 +3960,10 @@ "translation": "An account with that username already exists. Please contact your Administrator." }, { + "id": "store.sql_user.save.username_exists.saml_app_error", + "translation": "An account with that username already exists. Please contact your Administrator." + }, + { "id": "store.sql_user.update.app_error", "translation": "We couldn't update the account" }, diff --git a/model/config.go b/model/config.go index a8c63b1eb..32994a279 100644 --- a/model/config.go +++ b/model/config.go @@ -227,6 +227,31 @@ type LocalizationSettings struct { AvailableLocales *string } +type SamlSettings struct { + // Basic + Enable *bool + Verify *bool + Encrypt *bool + + IdpUrl *string + IdpDescriptorUrl *string + AssertionConsumerServiceURL *string + + IdpCertificateFile *string + PublicCertificateFile *string + PrivateKeyFile *string + + // User Mapping + FirstNameAttribute *string + LastNameAttribute *string + EmailAttribute *string + UsernameAttribute *string + NicknameAttribute *string + LocaleAttribute *string + + LoginButtonText *string +} + type Config struct { ServiceSettings ServiceSettings TeamSettings TeamSettings @@ -242,6 +267,7 @@ type Config struct { LdapSettings LdapSettings ComplianceSettings ComplianceSettings LocalizationSettings LocalizationSettings + SamlSettings SamlSettings } func (o *Config) ToJson() string { @@ -627,6 +653,86 @@ func (o *Config) SetDefaults() { o.LocalizationSettings.AvailableLocales = new(string) *o.LocalizationSettings.AvailableLocales = "" } + + if o.SamlSettings.Enable == nil { + o.SamlSettings.Enable = new(bool) + *o.SamlSettings.Enable = false + } + + if o.SamlSettings.Verify == nil { + o.SamlSettings.Verify = new(bool) + *o.SamlSettings.Verify = false + } + + if o.SamlSettings.Encrypt == nil { + o.SamlSettings.Encrypt = new(bool) + *o.SamlSettings.Encrypt = false + } + + if o.SamlSettings.IdpUrl == nil { + o.SamlSettings.IdpUrl = new(string) + *o.SamlSettings.IdpUrl = "" + } + + if o.SamlSettings.IdpDescriptorUrl == nil { + o.SamlSettings.IdpDescriptorUrl = new(string) + *o.SamlSettings.IdpDescriptorUrl = "" + } + + if o.SamlSettings.IdpCertificateFile == nil { + o.SamlSettings.IdpCertificateFile = new(string) + *o.SamlSettings.IdpCertificateFile = "" + } + + if o.SamlSettings.PublicCertificateFile == nil { + o.SamlSettings.PublicCertificateFile = new(string) + *o.SamlSettings.PublicCertificateFile = "" + } + + if o.SamlSettings.PrivateKeyFile == nil { + o.SamlSettings.PrivateKeyFile = new(string) + *o.SamlSettings.PrivateKeyFile = "" + } + + if o.SamlSettings.AssertionConsumerServiceURL == nil { + o.SamlSettings.AssertionConsumerServiceURL = new(string) + *o.SamlSettings.AssertionConsumerServiceURL = "" + } + + if o.SamlSettings.LoginButtonText == nil || *o.SamlSettings.LoginButtonText == "" { + o.SamlSettings.LoginButtonText = new(string) + *o.SamlSettings.LoginButtonText = USER_AUTH_SERVICE_SAML_TEXT + } + + if o.SamlSettings.FirstNameAttribute == nil { + o.SamlSettings.FirstNameAttribute = new(string) + *o.SamlSettings.FirstNameAttribute = "" + } + + if o.SamlSettings.LastNameAttribute == nil { + o.SamlSettings.LastNameAttribute = new(string) + *o.SamlSettings.LastNameAttribute = "" + } + + if o.SamlSettings.EmailAttribute == nil { + o.SamlSettings.EmailAttribute = new(string) + *o.SamlSettings.EmailAttribute = "" + } + + if o.SamlSettings.UsernameAttribute == nil { + o.SamlSettings.UsernameAttribute = new(string) + *o.SamlSettings.UsernameAttribute = "" + } + + if o.SamlSettings.NicknameAttribute == nil { + o.SamlSettings.NicknameAttribute = new(string) + *o.SamlSettings.NicknameAttribute = "" + } + + if o.SamlSettings.LocaleAttribute == nil { + o.SamlSettings.LocaleAttribute = new(string) + *o.SamlSettings.LocaleAttribute = "" + } } func (o *Config) IsValid() *AppError { @@ -749,6 +855,56 @@ func (o *Config) IsValid() *AppError { } } + if *o.SamlSettings.Enable { + if len(*o.SamlSettings.IdpUrl) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_idp_url.app_error", nil, "") + } + + if len(*o.SamlSettings.IdpDescriptorUrl) == 0 || !IsValidHttpUrl(*o.SamlSettings.IdpDescriptorUrl) { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_idp_descriptor_url.app_error", nil, "") + } + + if len(*o.SamlSettings.IdpCertificateFile) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_idp_cert.app_error", nil, "") + } + + if len(*o.SamlSettings.EmailAttribute) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_email_attribute.app_error", nil, "") + } + + if len(*o.SamlSettings.UsernameAttribute) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_username_attribute.app_error", nil, "") + } + + if len(*o.SamlSettings.FirstNameAttribute) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_first_name_attribute.app_error", nil, "") + } + + if len(*o.SamlSettings.LastNameAttribute) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_last_name_attribute.app_error", nil, "") + } + + if *o.SamlSettings.Verify { + if len(*o.SamlSettings.AssertionConsumerServiceURL) == 0 || !IsValidHttpUrl(*o.SamlSettings.AssertionConsumerServiceURL) { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_assertion_consumer_service_url.app_error", nil, "") + } + } + + if *o.SamlSettings.Encrypt { + if len(*o.SamlSettings.PrivateKeyFile) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_private_key.app_error", nil, "") + } + + if len(*o.SamlSettings.PublicCertificateFile) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_public_cert.app_error", nil, "") + } + } + + if len(*o.SamlSettings.EmailAttribute) == 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.saml_email_attribute.app_error", nil, "") + } + } + return nil } diff --git a/model/license.go b/model/license.go index bc72ff9ad..9781e3bf0 100644 --- a/model/license.go +++ b/model/license.go @@ -39,6 +39,7 @@ type Features struct { Compliance *bool `json:"compliance"` CustomBrand *bool `json:"custom_brand"` MHPNS *bool `json:"mhpns"` + SAML *bool `json:"saml"` FutureFeatures *bool `json:"future_features"` } @@ -82,6 +83,11 @@ func (f *Features) SetDefaults() { f.MHPNS = new(bool) *f.MHPNS = *f.FutureFeatures } + + if f.SAML == nil { + f.SAML = new(bool) + *f.SAML = *f.FutureFeatures + } } func (l *License) IsExpired() bool { diff --git a/model/saml.go b/model/saml.go new file mode 100644 index 000000000..16d3845da --- /dev/null +++ b/model/saml.go @@ -0,0 +1,18 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +const ( + USER_AUTH_SERVICE_SAML = "saml" + USER_AUTH_SERVICE_SAML_TEXT = "With SAML" + SAML_IDP_CERTIFICATE = 1 + SAML_PRIVATE_KEY = 2 + SAML_PUBLIC_CERT = 3 +) + +type SamlAuthRequest struct { + Base64AuthRequest string + URL string + RelayState string +} diff --git a/utils/config.go b/utils/config.go index 922709786..abb24c085 100644 --- a/utils/config.go +++ b/utils/config.go @@ -192,6 +192,10 @@ func LoadConfig(fileName string) { // This restarts the job if nessisary (works for config reloads) ldapI.StartLdapSyncJob() } + + if samlI := einterfaces.GetSamlInterface(); samlI != nil { + samlI.ConfigureSP() + } } func getClientConfig(c *model.Config) map[string]string { @@ -277,6 +281,11 @@ func getClientConfig(c *model.Config) map[string]string { if *License.Features.Compliance { props["EnableCompliance"] = strconv.FormatBool(*c.ComplianceSettings.Enable) } + + if *License.Features.SAML { + props["EnableSaml"] = strconv.FormatBool(*c.SamlSettings.Enable) + props["SamlLoginButtonText"] = *c.SamlSettings.LoginButtonText + } } return props diff --git a/utils/license.go b/utils/license.go index 060beb525..b80e1abc2 100644 --- a/utils/license.go +++ b/utils/license.go @@ -121,6 +121,7 @@ func getClientLicense(l *model.License) map[string]string { props["Users"] = strconv.Itoa(*l.Features.Users) props["LDAP"] = strconv.FormatBool(*l.Features.LDAP) props["MFA"] = strconv.FormatBool(*l.Features.MFA) + props["SAML"] = strconv.FormatBool(*l.Features.SAML) props["GoogleSSO"] = strconv.FormatBool(*l.Features.GoogleSSO) props["Compliance"] = strconv.FormatBool(*l.Features.Compliance) props["CustomBrand"] = strconv.FormatBool(*l.Features.CustomBrand) diff --git a/vendor/github.com/kardianos/osext/LICENSE b/vendor/github.com/kardianos/osext/LICENSE new file mode 100644 index 000000000..744875676 --- /dev/null +++ b/vendor/github.com/kardianos/osext/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/kardianos/osext/README.md b/vendor/github.com/kardianos/osext/README.md new file mode 100644 index 000000000..61350baba --- /dev/null +++ b/vendor/github.com/kardianos/osext/README.md @@ -0,0 +1,16 @@ +### Extensions to the "os" package. + +## Find the current Executable and ExecutableFolder. + +There is sometimes utility in finding the current executable file +that is running. This can be used for upgrading the current executable +or finding resources located relative to the executable file. Both +working directory and the os.Args[0] value are arbitrary and cannot +be relied on; os.Args[0] can be "faked". + +Multi-platform and supports: + * Linux + * OS X + * Windows + * Plan 9 + * BSDs. diff --git a/vendor/github.com/kardianos/osext/osext.go b/vendor/github.com/kardianos/osext/osext.go new file mode 100644 index 000000000..17f380f0e --- /dev/null +++ b/vendor/github.com/kardianos/osext/osext.go @@ -0,0 +1,33 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Extensions to the standard "os" package. +package osext // import "github.com/kardianos/osext" + +import "path/filepath" + +var cx, ce = executableClean() + +func executableClean() (string, error) { + p, err := executable() + return filepath.Clean(p), err +} + +// Executable returns an absolute path that can be used to +// re-invoke the current program. +// It may not be valid after the current program exits. +func Executable() (string, error) { + return cx, ce +} + +// Returns same path as Executable, returns just the folder +// path. Excludes the executable name and any trailing slash. +func ExecutableFolder() (string, error) { + p, err := Executable() + if err != nil { + return "", err + } + + return filepath.Dir(p), nil +} diff --git a/vendor/github.com/kardianos/osext/osext_plan9.go b/vendor/github.com/kardianos/osext/osext_plan9.go new file mode 100644 index 000000000..655750c54 --- /dev/null +++ b/vendor/github.com/kardianos/osext/osext_plan9.go @@ -0,0 +1,20 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package osext + +import ( + "os" + "strconv" + "syscall" +) + +func executable() (string, error) { + f, err := os.Open("/proc/" + strconv.Itoa(os.Getpid()) + "/text") + if err != nil { + return "", err + } + defer f.Close() + return syscall.Fd2path(int(f.Fd())) +} diff --git a/vendor/github.com/kardianos/osext/osext_procfs.go b/vendor/github.com/kardianos/osext/osext_procfs.go new file mode 100644 index 000000000..d59847ee5 --- /dev/null +++ b/vendor/github.com/kardianos/osext/osext_procfs.go @@ -0,0 +1,36 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build linux netbsd solaris dragonfly + +package osext + +import ( + "errors" + "fmt" + "os" + "runtime" + "strings" +) + +func executable() (string, error) { + switch runtime.GOOS { + case "linux": + const deletedTag = " (deleted)" + execpath, err := os.Readlink("/proc/self/exe") + if err != nil { + return execpath, err + } + execpath = strings.TrimSuffix(execpath, deletedTag) + execpath = strings.TrimPrefix(execpath, deletedTag) + return execpath, nil + case "netbsd": + return os.Readlink("/proc/curproc/exe") + case "dragonfly": + return os.Readlink("/proc/curproc/file") + case "solaris": + return os.Readlink(fmt.Sprintf("/proc/%d/path/a.out", os.Getpid())) + } + return "", errors.New("ExecPath not implemented for " + runtime.GOOS) +} diff --git a/vendor/github.com/kardianos/osext/osext_sysctl.go b/vendor/github.com/kardianos/osext/osext_sysctl.go new file mode 100644 index 000000000..66da0bcf9 --- /dev/null +++ b/vendor/github.com/kardianos/osext/osext_sysctl.go @@ -0,0 +1,126 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build darwin freebsd openbsd + +package osext + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" + "unsafe" +) + +var initCwd, initCwdErr = os.Getwd() + +func executable() (string, error) { + var mib [4]int32 + switch runtime.GOOS { + case "freebsd": + mib = [4]int32{1 /* CTL_KERN */, 14 /* KERN_PROC */, 12 /* KERN_PROC_PATHNAME */, -1} + case "darwin": + mib = [4]int32{1 /* CTL_KERN */, 38 /* KERN_PROCARGS */, int32(os.Getpid()), -1} + case "openbsd": + mib = [4]int32{1 /* CTL_KERN */, 55 /* KERN_PROC_ARGS */, int32(os.Getpid()), 1 /* KERN_PROC_ARGV */} + } + + n := uintptr(0) + // Get length. + _, _, errNum := syscall.Syscall6(syscall.SYS___SYSCTL, uintptr(unsafe.Pointer(&mib[0])), 4, 0, uintptr(unsafe.Pointer(&n)), 0, 0) + if errNum != 0 { + return "", errNum + } + if n == 0 { // This shouldn't happen. + return "", nil + } + buf := make([]byte, n) + _, _, errNum = syscall.Syscall6(syscall.SYS___SYSCTL, uintptr(unsafe.Pointer(&mib[0])), 4, uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&n)), 0, 0) + if errNum != 0 { + return "", errNum + } + if n == 0 { // This shouldn't happen. + return "", nil + } + + var execPath string + switch runtime.GOOS { + case "openbsd": + // buf now contains **argv, with pointers to each of the C-style + // NULL terminated arguments. + var args []string + argv := uintptr(unsafe.Pointer(&buf[0])) + Loop: + for { + argp := *(**[1 << 20]byte)(unsafe.Pointer(argv)) + if argp == nil { + break + } + for i := 0; uintptr(i) < n; i++ { + // we don't want the full arguments list + if string(argp[i]) == " " { + break Loop + } + if argp[i] != 0 { + continue + } + args = append(args, string(argp[:i])) + n -= uintptr(i) + break + } + if n < unsafe.Sizeof(argv) { + break + } + argv += unsafe.Sizeof(argv) + n -= unsafe.Sizeof(argv) + } + execPath = args[0] + // There is no canonical way to get an executable path on + // OpenBSD, so check PATH in case we are called directly + if execPath[0] != '/' && execPath[0] != '.' { + execIsInPath, err := exec.LookPath(execPath) + if err == nil { + execPath = execIsInPath + } + } + default: + for i, v := range buf { + if v == 0 { + buf = buf[:i] + break + } + } + execPath = string(buf) + } + + var err error + // execPath will not be empty due to above checks. + // Try to get the absolute path if the execPath is not rooted. + if execPath[0] != '/' { + execPath, err = getAbs(execPath) + if err != nil { + return execPath, err + } + } + // For darwin KERN_PROCARGS may return the path to a symlink rather than the + // actual executable. + if runtime.GOOS == "darwin" { + if execPath, err = filepath.EvalSymlinks(execPath); err != nil { + return execPath, err + } + } + return execPath, nil +} + +func getAbs(execPath string) (string, error) { + if initCwdErr != nil { + return execPath, initCwdErr + } + // The execPath may begin with a "../" or a "./" so clean it first. + // Join the two paths, trailing and starting slashes undetermined, so use + // the generic Join function. + return filepath.Join(initCwd, filepath.Clean(execPath)), nil +} diff --git a/vendor/github.com/kardianos/osext/osext_test.go b/vendor/github.com/kardianos/osext/osext_test.go new file mode 100644 index 000000000..eb18236c0 --- /dev/null +++ b/vendor/github.com/kardianos/osext/osext_test.go @@ -0,0 +1,203 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build darwin linux freebsd netbsd windows openbsd + +package osext + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +const ( + executableEnvVar = "OSTEST_OUTPUT_EXECUTABLE" + + executableEnvValueMatch = "match" + executableEnvValueDelete = "delete" +) + +func TestPrintExecutable(t *testing.T) { + ef, err := Executable() + if err != nil { + t.Fatalf("Executable failed: %v", err) + } + t.Log("Executable:", ef) +} +func TestPrintExecutableFolder(t *testing.T) { + ef, err := ExecutableFolder() + if err != nil { + t.Fatalf("ExecutableFolder failed: %v", err) + } + t.Log("Executable Folder:", ef) +} +func TestExecutableFolder(t *testing.T) { + ef, err := ExecutableFolder() + if err != nil { + t.Fatalf("ExecutableFolder failed: %v", err) + } + if ef[len(ef)-1] == filepath.Separator { + t.Fatal("ExecutableFolder ends with a trailing slash.") + } +} +func TestExecutableMatch(t *testing.T) { + ep, err := Executable() + if err != nil { + t.Fatalf("Executable failed: %v", err) + } + + // fullpath to be of the form "dir/prog". + dir := filepath.Dir(filepath.Dir(ep)) + fullpath, err := filepath.Rel(dir, ep) + if err != nil { + t.Fatalf("filepath.Rel: %v", err) + } + // Make child start with a relative program path. + // Alter argv[0] for child to verify getting real path without argv[0]. + cmd := &exec.Cmd{ + Dir: dir, + Path: fullpath, + Env: []string{fmt.Sprintf("%s=%s", executableEnvVar, executableEnvValueMatch)}, + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("exec(self) failed: %v", err) + } + outs := string(out) + if !filepath.IsAbs(outs) { + t.Fatalf("Child returned %q, want an absolute path", out) + } + if !sameFile(outs, ep) { + t.Fatalf("Child returned %q, not the same file as %q", out, ep) + } +} + +func TestExecutableDelete(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip() + } + fpath, err := Executable() + if err != nil { + t.Fatalf("Executable failed: %v", err) + } + + r, w := io.Pipe() + stderrBuff := &bytes.Buffer{} + stdoutBuff := &bytes.Buffer{} + cmd := &exec.Cmd{ + Path: fpath, + Env: []string{fmt.Sprintf("%s=%s", executableEnvVar, executableEnvValueDelete)}, + Stdin: r, + Stderr: stderrBuff, + Stdout: stdoutBuff, + } + err = cmd.Start() + if err != nil { + t.Fatalf("exec(self) start failed: %v", err) + } + + tempPath := fpath + "_copy" + _ = os.Remove(tempPath) + + err = copyFile(tempPath, fpath) + if err != nil { + t.Fatalf("copy file failed: %v", err) + } + err = os.Remove(fpath) + if err != nil { + t.Fatalf("remove running test file failed: %v", err) + } + err = os.Rename(tempPath, fpath) + if err != nil { + t.Fatalf("rename copy to previous name failed: %v", err) + } + + w.Write([]byte{0}) + w.Close() + + err = cmd.Wait() + if err != nil { + t.Fatalf("exec wait failed: %v", err) + } + + childPath := stderrBuff.String() + if !filepath.IsAbs(childPath) { + t.Fatalf("Child returned %q, want an absolute path", childPath) + } + if !sameFile(childPath, fpath) { + t.Fatalf("Child returned %q, not the same file as %q", childPath, fpath) + } +} + +func sameFile(fn1, fn2 string) bool { + fi1, err := os.Stat(fn1) + if err != nil { + return false + } + fi2, err := os.Stat(fn2) + if err != nil { + return false + } + return os.SameFile(fi1, fi2) +} +func copyFile(dest, src string) error { + df, err := os.Create(dest) + if err != nil { + return err + } + defer df.Close() + + sf, err := os.Open(src) + if err != nil { + return err + } + defer sf.Close() + + _, err = io.Copy(df, sf) + return err +} + +func TestMain(m *testing.M) { + env := os.Getenv(executableEnvVar) + switch env { + case "": + os.Exit(m.Run()) + case executableEnvValueMatch: + // First chdir to another path. + dir := "/" + if runtime.GOOS == "windows" { + dir = filepath.VolumeName(".") + } + os.Chdir(dir) + if ep, err := Executable(); err != nil { + fmt.Fprint(os.Stderr, "ERROR: ", err) + } else { + fmt.Fprint(os.Stderr, ep) + } + case executableEnvValueDelete: + bb := make([]byte, 1) + var err error + n, err := os.Stdin.Read(bb) + if err != nil { + fmt.Fprint(os.Stderr, "ERROR: ", err) + os.Exit(2) + } + if n != 1 { + fmt.Fprint(os.Stderr, "ERROR: n != 1, n == ", n) + os.Exit(2) + } + if ep, err := Executable(); err != nil { + fmt.Fprint(os.Stderr, "ERROR: ", err) + } else { + fmt.Fprint(os.Stderr, ep) + } + } + os.Exit(0) +} diff --git a/vendor/github.com/kardianos/osext/osext_windows.go b/vendor/github.com/kardianos/osext/osext_windows.go new file mode 100644 index 000000000..72d282cf8 --- /dev/null +++ b/vendor/github.com/kardianos/osext/osext_windows.go @@ -0,0 +1,34 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package osext + +import ( + "syscall" + "unicode/utf16" + "unsafe" +) + +var ( + kernel = syscall.MustLoadDLL("kernel32.dll") + getModuleFileNameProc = kernel.MustFindProc("GetModuleFileNameW") +) + +// GetModuleFileName() with hModule = NULL +func executable() (exePath string, err error) { + return getModuleFileName() +} + +func getModuleFileName() (string, error) { + var n uint32 + b := make([]uint16, syscall.MAX_PATH) + size := uint32(len(b)) + + r0, _, e1 := getModuleFileNameProc.Call(0, uintptr(unsafe.Pointer(&b[0])), uintptr(size)) + n = uint32(r0) + if n == 0 { + return "", e1 + } + return string(utf16.Decode(b[0:n])), nil +} diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index 8f88afab4..5a31519c9 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -176,6 +176,7 @@ export default class AdminSidebar extends React.Component { render() { let ldapSettings = null; + let samlSettings = null; let complianceSettings = null; let license = null; @@ -198,6 +199,20 @@ export default class AdminSidebar extends React.Component { ); } + if (global.window.mm_license.SAML === 'true') { + samlSettings = ( + <AdminSidebarSection + name='saml' + title={ + <FormattedMessage + id='admin.sidebar.saml' + defaultMessage='SAML' + /> + } + /> + ); + } + if (global.window.mm_license.Compliance === 'true') { complianceSettings = ( <AdminSidebarSection @@ -391,6 +406,7 @@ export default class AdminSidebar extends React.Component { } /> {ldapSettings} + {samlSettings} </AdminSidebarSection> <AdminSidebarSection name='security' diff --git a/webapp/components/admin_console/file_upload_setting.jsx b/webapp/components/admin_console/file_upload_setting.jsx new file mode 100644 index 000000000..e7cb387ee --- /dev/null +++ b/webapp/components/admin_console/file_upload_setting.jsx @@ -0,0 +1,124 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Setting from './setting.jsx'; + +import * as Utils from 'utils/utils.jsx'; + +export default class FileUploadSetting extends Setting { + static get propTypes() { + return { + id: React.PropTypes.string.isRequired, + label: React.PropTypes.node.isRequired, + helpText: React.PropTypes.node, + uploadingText: React.PropTypes.node, + onSubmit: React.PropTypes.func.isRequired, + disabled: React.PropTypes.bool, + fileType: React.PropTypes.string.isRequired + }; + } + + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + fileName: null + }; + } + + handleChange() { + const files = this.refs.fileInput.files; + if (files && files.length > 0) { + this.setState({fileSelected: true, fileName: files[0].name}); + } + } + + handleSubmit(e) { + e.preventDefault(); + + $(this.refs.upload_button).button('loading'); + this.props.onSubmit(this.props.id, this.refs.fileInput.files[0], (error) => { + $(this.refs.upload_button).button('reset'); + if (error) { + Utils.clearFileInput(this.refs.fileInput); + } + this.setState({fileSelected: false, fileName: null, serverError: error}); + }); + } + + render() { + let serverError; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + var btnClass = 'btn'; + if (this.state.fileSelected) { + btnClass = 'btn btn-primary'; + } + + let fileName; + if (this.state.fileName) { + fileName = this.state.fileName; + } else { + fileName = ( + <FormattedMessage + id='admin.file_upload.noFile' + defaultMessage='No file uploaded' + /> + ); + } + + return ( + <Setting + label={this.props.label} + helpText={this.props.helpText} + inputId={this.props.id} + > + <div> + <div className='file__upload'> + <button + className='btn btn-default' + disabled={this.props.disabled} + > + <FormattedMessage + id='admin.file_upload.chooseFile' + defaultMessage='Choose File' + /> + </button> + <input + ref='fileInput' + type='file' + disabled={this.props.disabled} + accept={this.props.fileType} + onChange={this.handleChange} + /> + </div> + <button + className={btnClass} + disabled={!this.state.fileSelected} + onClick={this.handleSubmit} + ref='upload_button' + data-loading-text={`<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> ${this.props.uploadingText}`} + > + <FormattedMessage + id='admin.file_upload.uploadFile' + defaultMessage='Upload' + /> + </button> + <div className='help-text no-margin'> + {fileName} + </div> + {serverError} + </div> + </Setting> + ); + } +} diff --git a/webapp/components/admin_console/remove_file_setting.jsx b/webapp/components/admin_console/remove_file_setting.jsx new file mode 100644 index 000000000..5a76faae2 --- /dev/null +++ b/webapp/components/admin_console/remove_file_setting.jsx @@ -0,0 +1,72 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import React from 'react'; + +import Setting from './setting.jsx'; + +export default class RemoveFileSetting extends Setting { + static get propTypes() { + return { + id: React.PropTypes.string.isRequired, + label: React.PropTypes.node.isRequired, + helpText: React.PropTypes.node, + removeButtonText: React.PropTypes.node.isRequired, + removingText: React.PropTypes.node, + fileName: React.PropTypes.string.isRequired, + onSubmit: React.PropTypes.func.isRequired, + disabled: React.PropTypes.bool + }; + } + + constructor(props) { + super(props); + this.handleRemove = this.handleRemove.bind(this); + + this.state = { + serverError: null + }; + } + + handleRemove(e) { + e.preventDefault(); + + $(this.refs.remove_button).button('loading'); + this.props.onSubmit(this.props.id, (error) => { + $(this.refs.remove_button).button('reset'); + this.setState({serverError: error}); + }); + } + + render() { + let serverError; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + return ( + <Setting + label={this.props.label} + helpText={this.props.helpText} + inputId={this.props.id} + > + <div> + <div className='help-text remove-filename'> + {this.props.fileName} + </div> + <button + className='btn btn-danger' + onClick={this.handleRemove} + ref='remove_button' + disabled={this.props.disabled} + data-loading-text={`<span class='glyphicon glyphicon-refresh glyphicon-refresh-animate'></span> ${this.props.removingText}`} + > + {this.props.removeButtonText} + </button> + {serverError} + </div> + </Setting> + ); + } +}
\ No newline at end of file diff --git a/webapp/components/admin_console/saml_settings.jsx b/webapp/components/admin_console/saml_settings.jsx new file mode 100644 index 000000000..db841aa83 --- /dev/null +++ b/webapp/components/admin_console/saml_settings.jsx @@ -0,0 +1,518 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import AdminSettings from './admin_settings.jsx'; +import BooleanSetting from './boolean_setting.jsx'; +import TextSetting from './text_setting.jsx'; +import FileUploadSetting from './file_upload_setting.jsx'; +import RemoveFileSetting from './remove_file_setting.jsx'; + +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import SettingsGroup from './settings_group.jsx'; + +import Client from 'utils/web_client.jsx'; +import * as Utils from 'utils/utils.jsx'; + +export default class SamlSettings extends AdminSettings { + constructor(props) { + super(props); + + this.getConfigFromState = this.getConfigFromState.bind(this); + + this.renderSettings = this.renderSettings.bind(this); + this.uploadCertificate = this.uploadCertificate.bind(this); + this.removeCertificate = this.removeCertificate.bind(this); + + const settings = props.config.SamlSettings; + + this.state = Object.assign(this.state, { + enable: settings.Enable, + verify: settings.Verify, + encrypt: settings.Encrypt, + idpUrl: settings.IdpUrl, + idpDescriptorUrl: settings.IdpDescriptorUrl, + assertionConsumerServiceURL: settings.AssertionConsumerServiceURL, + idpCertificateFile: settings.IdpCertificateFile, + publicCertificateFile: settings.PublicCertificateFile, + privateKeyFile: settings.PrivateKeyFile, + firstNameAttribute: settings.FirstNameAttribute, + lastNameAttribute: settings.LastNameAttribute, + emailAttribute: settings.EmailAttribute, + usernameAttribute: settings.UsernameAttribute, + nicknameAttribute: settings.NicknameAttribute, + localeAttribute: settings.LocaleAttribute, + loginButtonText: settings.LoginButtonText + }); + } + + getConfigFromState(config) { + config.SamlSettings.Enable = this.state.enable; + config.SamlSettings.Verify = this.state.verify; + config.SamlSettings.Encrypt = this.state.encrypt; + config.SamlSettings.IdpUrl = this.state.idpUrl; + config.SamlSettings.IdpDescriptorUrl = this.state.idpDescriptorUrl; + config.SamlSettings.AssertionConsumerServiceURL = this.state.assertionConsumerServiceURL; + config.SamlSettings.IdpCertificateFile = this.state.idpCertificateFile; + config.SamlSettings.PublicCertificateFile = this.state.publicCertificateFile; + config.SamlSettings.PrivateKeyFile = this.state.privateKeyFile; + config.SamlSettings.FirstNameAttribute = this.state.firstNameAttribute; + config.SamlSettings.LastNameAttribute = this.state.lastNameAttribute; + config.SamlSettings.EmailAttribute = this.state.emailAttribute; + config.SamlSettings.UsernameAttribute = this.state.usernameAttribute; + config.SamlSettings.NicknameAttribute = this.state.nicknameAttribute; + config.SamlSettings.LocaleAttribute = this.state.localeAttribute; + config.SamlSettings.LoginButtonText = this.state.loginButtonText; + + return config; + } + + uploadCertificate(id, file, callback) { + Client.uploadCertificateFile( + file, + () => { + const fileName = file.name; + this.handleChange(id, fileName); + this.setState({[id]: fileName}); + if (callback && typeof callback === 'function') { + callback(); + } + }, + (error) => { + if (callback && typeof callback === 'function') { + callback(error.message); + } + } + ); + } + + removeCertificate(id, callback) { + Client.removeCertificateFile( + this.state[id], + () => { + this.handleChange(id, ''); + this.setState({[id]: null}); + }, + (error) => { + if (callback && typeof callback === 'function') { + callback(error.message); + } + } + ); + } + + renderTitle() { + return ( + <h3> + <FormattedMessage + id='admin.authentication.saml' + defaultMessage='SAML' + /> + </h3> + ); + } + + renderSettings() { + const licenseEnabled = global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.SAML === 'true'; + if (!licenseEnabled) { + return null; + } + + let idpCert; + let privKey; + let pubCert; + + if (this.state.idpCertificateFile) { + idpCert = ( + <RemoveFileSetting + id='idpCertificateFile' + label={ + <FormattedMessage + id='admin.saml.idpCertificateFileTitle' + defaultMessage='Identity Provider Public Certificate:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.idpCertificateFileRemoveDesc' + defaultMessage='Remove the public authentication certificate issued by your Identity Provider.' + /> + } + removeButtonText={Utils.localizeMessage('admin.saml.remove.idp_certificate', 'Remove Identity Provider Certificate')} + removingText={Utils.localizeMessage('admin.saml.removing.certificate', 'Removing Certificate...')} + fileName={this.state.idpCertificateFile} + onSubmit={this.removeCertificate} + disabled={!this.state.enable} + /> + ); + } else { + idpCert = ( + <FileUploadSetting + id='idpCertificateFile' + label={ + <FormattedMessage + id='admin.saml.idpCertificateFileTitle' + defaultMessage='Identity Provider Public Certificate:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.idpCertificateFileDesc' + defaultMessage='The public authentication certificate issued by your Identity Provider.' + /> + } + uploadingText={Utils.localizeMessage('admin.saml.uploading.certificate', 'Uploading Certificate...')} + disabled={!this.state.enable} + fileType='.crt' + onSubmit={this.uploadCertificate} + /> + ); + } + + if (this.state.privateKeyFile) { + privKey = ( + <RemoveFileSetting + id='privateKeyFile' + label={ + <FormattedMessage + id='admin.saml.privateKeyFileTitle' + defaultMessage='Service Provider Private Key:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.privateKeyFileFileRemoveDesc' + defaultMessage='Remove the private key used to decrypt SAML Assertions from the Identity Provider.' + /> + } + removeButtonText={Utils.localizeMessage('admin.saml.remove.privKey', 'Remove Service Provider Private Key')} + removingText={Utils.localizeMessage('admin.saml.removing.privKey', 'Removing Private Key...')} + fileName={this.state.privateKeyFile} + onSubmit={this.removeCertificate} + disabled={!this.state.enable || !this.state.encrypt} + /> + ); + } else { + privKey = ( + <FileUploadSetting + id='privateKeyFile' + label={ + <FormattedMessage + id='admin.saml.privateKeyFileTitle' + defaultMessage='Service Provider Private Key:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.privateKeyFileFileDesc' + defaultMessage='The private key used to decrypt SAML Assertions from the Identity Provider.' + /> + } + uploadingText={Utils.localizeMessage('admin.saml.uploading.privateKey', 'Uploading Private Key...')} + disabled={!this.state.enable || !this.state.encrypt} + fileType='.key' + onSubmit={this.uploadCertificate} + /> + ); + } + + if (this.state.publicCertificateFile) { + pubCert = ( + <RemoveFileSetting + id='publicCertificateFile' + label={ + <FormattedMessage + id='admin.saml.publicCertificateFileTitle' + defaultMessage='Service Provider Public Certificate:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.publicCertificateFileRemoveDesc' + defaultMessage='Remove the certificate used to generate the signature on a SAML request to the Identity Provider for a service provider initiated SAML login, when Mattermost is the Service Provider.' + /> + } + removeButtonText={Utils.localizeMessage('admin.saml.remove.sp_certificate', 'Remove Service Provider Certificate')} + removingText={Utils.localizeMessage('admin.saml.removing.certificate', 'Removing Certificate...')} + fileName={this.state.publicCertificateFile} + onSubmit={this.removeCertificate} + disabled={!this.state.enable || !this.state.encrypt} + /> + ); + } else { + pubCert = ( + <FileUploadSetting + id='publicCertificateFile' + label={ + <FormattedMessage + id='admin.saml.publicCertificateFileTitle' + defaultMessage='Service Provider Public Certificate:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.publicCertificateFileDesc' + defaultMessage='The certificate used to generate the signature on a SAML request to the Identity Provider for a service provider initiated SAML login, when Mattermost is the Service Provider.' + /> + } + uploadingText={Utils.localizeMessage('admin.saml.uploading.certificate', 'Uploading Certificate...')} + disabled={!this.state.enable || !this.state.encrypt} + fileType='.crt' + onSubmit={this.uploadCertificate} + /> + ); + } + + return ( + <SettingsGroup> + <BooleanSetting + id='enable' + label={ + <FormattedMessage + id='admin.saml.enableTitle' + defaultMessage='Enable Login With SAML:' + /> + } + helpText={ + <FormattedHTMLMessage + id='admin.saml.enableDescription' + defaultMessage='When true, Mattermost allows login using SAML. Please see <a href="http://docs.mattermost.com/deployment/sso-saml.html" target="_blank">documentation</a> to learn more about configuring SAML for Mattermost.' + /> + } + value={this.state.enable} + onChange={this.handleChange} + /> + <TextSetting + id='idpUrl' + label={ + <FormattedMessage + id='admin.saml.idpUrlTitle' + defaultMessage='SAML SSO URL:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.idpUrlEx', 'Ex "https://idp.example.org/SAML2/SSO/Login"')} + helpText={ + <FormattedMessage + id='admin.saml.idpUrlDesc' + defaultMessage='The URL where Mattermost sends a SAML request to start login sequence.' + /> + } + value={this.state.idpUrl} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='idpDescriptorUrl' + label={ + <FormattedMessage + id='admin.saml.idpDescriptorUrlTitle' + defaultMessage='Identity Provider Issuer URL:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.idpDescriptorUrlEx', 'Ex "https://idp.example.org/SAML2/issuer"')} + helpText={ + <FormattedMessage + id='admin.saml.idpDescriptorUrlDesc' + defaultMessage='The issuer URL for the Identity Provider you use for SAML requests.' + /> + } + value={this.state.idpDescriptorUrl} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + {idpCert} + <BooleanSetting + id='verify' + label={ + <FormattedMessage + id='admin.saml.verifyTitle' + defaultMessage='Verify Signature:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.verifyDescription' + defaultMessage='When true, Mattermost verifies that the signature sent from the SAML Response matches the Service Provider Login URL' + /> + } + value={this.state.verify} + disabled={!this.state.enable} + onChange={this.handleChange} + /> + <TextSetting + id='assertionConsumerServiceURL' + label={ + <FormattedMessage + id='admin.saml.assertionConsumerServiceURLTitle' + defaultMessage='Service Provider Login URL:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.assertionConsumerServiceURLEx', 'Ex "https://<your-mattermost-url>/login/sso/saml"')} + helpText={ + <FormattedMessage + id='admin.saml.assertionConsumerServiceURLDesc' + defaultMessage='Enter https://<your-mattermost-url>/login/sso/saml. Make sure you use HTTP or HTTPS in your URL depending on your server configuration. This field is also known as the Assertion Consumer Service URL.' + /> + } + value={this.state.assertionConsumerServiceURL} + onChange={this.handleChange} + disabled={!this.state.enable || !this.state.verify} + /> + <BooleanSetting + id='encrypt' + label={ + <FormattedMessage + id='admin.saml.encryptTitle' + defaultMessage='Enable Encryption:' + /> + } + helpText={ + <FormattedMessage + id='admin.saml.encryptDescription' + defaultMessage='When true, Mattermost will decrypt SAML Assertions encrypted with your Service Provider Public Certificate.' + /> + } + value={this.state.encrypt} + disabled={!this.state.enable} + onChange={this.handleChange} + /> + {privKey} + {pubCert} + <TextSetting + id='emailAttribute' + label={ + <FormattedMessage + id='admin.saml.emailAttrTitle' + defaultMessage='Email Attribute:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.emailAttrEx', 'Ex "Email" or "PrimaryEmail"')} + helpText={ + <FormattedMessage + id='admin.saml.emailAttrDesc' + defaultMessage='The attribute in the SAML Assertion that will be used to populate the email addresses of users in Mattermost.' + /> + } + value={this.state.emailAttribute} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='usernameAttribute' + label={ + <FormattedMessage + id='admin.saml.usernameAttrTitle' + defaultMessage='Username Attribute:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.usernameAttrEx', 'Ex "Username"')} + helpText={ + <FormattedMessage + id='admin.saml.usernameAttrDesc' + defaultMessage='The attribute in the SAML Assertion that will be used to populate the username field in Mattermost.' + /> + } + value={this.state.usernameAttribute} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='firstNameAttribute' + label={ + <FormattedMessage + id='admin.saml.firstnameAttrTitle' + defaultMessage='First Name Attribute:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.firstnameAttrEx', 'Ex "FirstName"')} + helpText={ + <FormattedMessage + id='admin.saml.firstnameAttrDesc' + defaultMessage='The attribute in the SAML Assertion that will be used to populate the first name of users in Mattermost.' + /> + } + value={this.state.firstNameAttribute} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='lastNameAttribute' + label={ + <FormattedMessage + id='admin.saml.lastnameAttrTitle' + defaultMessage='Last Name Attribute:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.lastnameAttrEx', 'Ex "LastName"')} + helpText={ + <FormattedMessage + id='admin.saml.lastnameAttrDesc' + defaultMessage='The attribute in the SAML Assertion that will be used to populate the last name of users in Mattermost.' + /> + } + value={this.state.lastNameAttribute} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='nicknameAttribute' + label={ + <FormattedMessage + id='admin.saml.nicknameAttrTitle' + defaultMessage='Nickname Attribute:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.nicknameAttrEx', 'Ex "Nickname"')} + helpText={ + <FormattedMessage + id='admin.saml.nicknameAttrDesc' + defaultMessage='(Optional) The attribute in the SAML Assertion that will be used to populate the nickname of users in Mattermost.' + /> + } + value={this.state.nicknameAttribute} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='localeAttribute' + label={ + <FormattedMessage + id='admin.saml.localeAttrTitle' + defaultMessage='Preferred Language Attribute:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.localeAttrEx', 'Ex "Locale" or "PrimaryLanguage"')} + helpText={ + <FormattedMessage + id='admin.saml.localeAttrDesc' + defaultMessage='(Optional) The attribute in the SAML Assertion that will be used to populate the language of users in Mattermost.' + /> + } + value={this.state.localeAttribute} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <TextSetting + id='loginButtonText' + label={ + <FormattedMessage + id='admin.saml.loginButtonTextTitle' + defaultMessage='Login Button Text:' + /> + } + placeholder={Utils.localizeMessage('admin.saml.loginButtonTextEx', 'Ex "With OKTA"')} + helpText={ + <FormattedMessage + id='admin.saml.loginButtonTextDesc' + defaultMessage='(Optional) The text that appears in the login button on the login page. Defaults to "With SAML".' + /> + } + value={this.state.loginButtonText} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + </SettingsGroup> + ); + } +}
\ No newline at end of file diff --git a/webapp/components/admin_console/user_item.jsx b/webapp/components/admin_console/user_item.jsx index 62de50f0f..edded5aab 100644 --- a/webapp/components/admin_console/user_item.jsx +++ b/webapp/components/admin_console/user_item.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import Client from 'utils/web_client.jsx'; +import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; import UserStore from 'stores/user_store.jsx'; import ConfirmModal from '../confirm_modal.jsx'; @@ -374,12 +375,13 @@ export default class UserItem extends React.Component { let authServiceText; let passwordReset; if (user.auth_service) { + const service = (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) ? user.auth_service.toUpperCase() : Utils.toTitleCase(user.auth_service); authServiceText = ( <FormattedHTMLMessage id='admin.user_item.authServiceNotEmail' defaultMessage=', <strong>Sign-in Method:</strong> {service}' values={{ - service: Utils.toTitleCase(user.auth_service) + service }} /> ); diff --git a/webapp/components/claim/components/email_to_oauth.jsx b/webapp/components/claim/components/email_to_oauth.jsx index 6b0a90e8e..422b31a3a 100644 --- a/webapp/components/claim/components/email_to_oauth.jsx +++ b/webapp/components/claim/components/email_to_oauth.jsx @@ -3,6 +3,7 @@ import * as Utils from 'utils/utils.jsx'; import Client from 'utils/web_client.jsx'; +import Constants from 'utils/constants.jsx'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -55,7 +56,8 @@ export default class EmailToOAuth extends React.Component { formClass += ' has-error'; } - const uiType = Utils.toTitleCase(this.props.newType) + ' SSO'; + const type = (this.props.newType === Constants.SAML_SERVICE ? Constants.SAML_SERVICE.toUpperCase() : Utils.toTitleCase(this.props.newType)); + const uiType = `${type} SSO`; return ( <div> @@ -74,7 +76,7 @@ export default class EmailToOAuth extends React.Component { id='claim.email_to_oauth.ssoType' defaultMessage='Upon claiming your account, you will only be able to login with {type} SSO' values={{ - type: Utils.toTitleCase(this.props.newType) + type }} /> </p> @@ -83,7 +85,7 @@ export default class EmailToOAuth extends React.Component { id='claim.email_to_oauth.ssoNote' defaultMessage='You must already have a valid {type} account' values={{ - type: Utils.toTitleCase(this.props.newType) + type }} /> </p> diff --git a/webapp/components/claim/components/oauth_to_email.jsx b/webapp/components/claim/components/oauth_to_email.jsx index 17ca12264..6a0f6431b 100644 --- a/webapp/components/claim/components/oauth_to_email.jsx +++ b/webapp/components/claim/components/oauth_to_email.jsx @@ -3,6 +3,7 @@ import * as Utils from 'utils/utils.jsx'; import Client from 'utils/web_client.jsx'; +import Constants from 'utils/constants.jsx'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -62,7 +63,7 @@ export default class OAuthToEmail extends React.Component { formClass += ' has-error'; } - const uiType = Utils.toTitleCase(this.props.currentType) + ' SSO'; + const uiType = `${(this.props.currentType === Constants.SAML_SERVICE ? Constants.SAML_SERVICE.toUpperCase() : Utils.toTitleCase(this.props.currentType))} SSO`; return ( <div> @@ -85,7 +86,7 @@ export default class OAuthToEmail extends React.Component { <p> <FormattedMessage id='claim.oauth_to_email.enterNewPwd' - defaultMessage='Enter a new password for your {site} account' + defaultMessage='Enter a new password for your {site} email account' values={{ site: global.window.mm_config.SiteName }} diff --git a/webapp/components/login/login_controller.jsx b/webapp/components/login/login_controller.jsx index 653908654..cd4175d3c 100644 --- a/webapp/components/login/login_controller.jsx +++ b/webapp/components/login/login_controller.jsx @@ -43,6 +43,7 @@ export default class LoginController extends React.Component { ldapEnabled: global.window.mm_license.IsLicensed === 'true' && global.window.mm_config.EnableLdap === 'true', usernameSigninEnabled: global.window.mm_config.EnableSignInWithUsername === 'true', emailSigninEnabled: global.window.mm_config.EnableSignInWithEmail === 'true', + samlEnabled: global.window.mm_license.IsLicensed === 'true' && global.window.mm_config.EnableSaml === 'true', loginId: '', // the browser will set a default for this password: '', showMfa: false @@ -319,6 +320,7 @@ export default class LoginController extends React.Component { const ldapEnabled = this.state.ldapEnabled; const gitlabSigninEnabled = global.window.mm_config.EnableSignUpWithGitLab === 'true'; const googleSigninEnabled = global.window.mm_config.EnableSignUpWithGoogle === 'true'; + const samlSigninEnabled = this.state.samlEnabled; const usernameSigninEnabled = this.state.usernameSigninEnabled; const emailSigninEnabled = this.state.emailSigninEnabled; @@ -416,7 +418,7 @@ export default class LoginController extends React.Component { ); } - if ((emailSigninEnabled || usernameSigninEnabled || ldapEnabled) && (gitlabSigninEnabled || googleSigninEnabled)) { + if ((emailSigninEnabled || usernameSigninEnabled || ldapEnabled) && (gitlabSigninEnabled || googleSigninEnabled || samlSigninEnabled)) { loginControls.push( <div key='divider' @@ -475,6 +477,20 @@ export default class LoginController extends React.Component { ); } + if (samlSigninEnabled) { + loginControls.push( + <a + className='btn btn-custom-login saml' + key='gitlab' + href={'/login/sso/saml' + this.props.location.search} + > + <span> + {window.mm_config.SamlLoginButtonText} + </span> + </a> + ); + } + return ( <div> {extraBox} diff --git a/webapp/components/signup_user_complete.jsx b/webapp/components/signup_user_complete.jsx index c7ddfc91b..fa5e9268e 100644 --- a/webapp/components/signup_user_complete.jsx +++ b/webapp/components/signup_user_complete.jsx @@ -588,6 +588,20 @@ export default class SignupUserComplete extends React.Component { ); } + if (global.window.mm_config.EnableSaml === 'true' && global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.SAML === 'true') { + signupMessage.push( + <a + className='btn btn-custom-login saml' + key='saml' + href={`/login/sso/saml${window.location.search}${window.location.search ? '&' : '?'}action=signup`} + > + <span> + {global.window.mm_config.SamlLoginButtonText} + </span> + </a> + ); + } + let ldapSignup; if (global.window.mm_config.EnableLdap === 'true' && global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.LDAP) { ldapSignup = ( diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx index f8910b9bc..5e821a26a 100644 --- a/webapp/components/user_settings/user_settings_general.jsx +++ b/webapp/components/user_settings/user_settings_general.jsx @@ -412,6 +412,24 @@ class UserSettingsGeneralTab extends React.Component { {helpText} </div> ); + } else if (this.props.user.auth_service === Constants.SAML_SERVICE) { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.general.emailSamlCantUpdate' + defaultMessage='Login occurs through SAML. Email cannot be updated. Email address used for notifications is {email}.' + values={{ + email: this.state.email + }} + /> + </div> + {helpText} + </div> + ); } emailSection = ( @@ -478,6 +496,16 @@ class UserSettingsGeneralTab extends React.Component { }} /> ); + } else if (this.props.user.auth_service === Constants.SAML_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.general.loginSaml' + defaultMessage='Login done through SAML ({email})' + values={{ + email: this.state.email + }} + /> + ); } emailSection = ( diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx index af7aeb3c6..247dc0f81 100644 --- a/webapp/components/user_settings/user_settings_security.jsx +++ b/webapp/components/user_settings/user_settings_security.jsx @@ -620,6 +620,24 @@ class SecurityTab extends React.Component { ); } + let samlOption; + if (global.window.mm_config.EnableSaml === 'true' && user.auth_service === '') { + samlOption = ( + <div> + <Link + className='btn btn-primary' + to={'/claim/email_to_oauth?email=' + encodeURIComponent(user.email) + '&old_type=' + user.auth_service + '&new_type=' + Constants.SAML_SERVICE} + > + <FormattedMessage + id='user.settings.security.switchSaml' + defaultMessage='Switch to using SAML SSO' + /> + </Link> + <br/> + </div> + ); + } + const inputs = []; inputs.push( <div key='userSignInOption'> @@ -627,6 +645,7 @@ class SecurityTab extends React.Component { {gitlabOption} <br/> {ldapOption} + {samlOption} {googleOption} </div> ); @@ -681,6 +700,13 @@ class SecurityTab extends React.Component { defaultMessage='LDAP' /> ); + } else if (this.props.user.auth_service === Constants.SAML_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.security.saml' + defaultMessage='SAML' + /> + ); } return ( @@ -701,6 +727,7 @@ class SecurityTab extends React.Component { numMethods = global.window.mm_config.EnableSignUpWithGitLab === 'true' ? numMethods + 1 : numMethods; numMethods = global.window.mm_config.EnableSignUpWithGoogle === 'true' ? numMethods + 1 : numMethods; numMethods = global.window.mm_config.EnableLdap === 'true' ? numMethods + 1 : numMethods; + numMethods = global.window.mm_config.EnableSaml === 'true' ? numMethods + 1 : numMethods; let signInSection; if (global.window.mm_config.EnableSignUpWithEmail === 'true' && numMethods > 0) { diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 02d11e484..26b1b47fd 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -102,6 +102,7 @@ "admin.audits.title": "User Activity Logs", "admin.authentication.email": "Email Auth", "admin.authentication.gitlab": "GitLab", + "admin.authentication.saml": "SAML", "admin.banner.heading": "Note:", "admin.compliance.directoryDescription": "Directory to which compliance reports are written. If blank, will be set to ./data/.", "admin.compliance.directoryExample": "Ex \"./data/\"", @@ -217,6 +218,9 @@ "admin.email.smtpUsernameTitle": "SMTP Server Username:", "admin.email.testing": "Testing...", "admin.false": "false", + "admin.file_upload.chooseFile": "Choose File", + "admin.file_upload.noFile": "No file uploaded", + "admin.file_upload.uploadFile": "Upload", "admin.files.images": "Images", "admin.files.storage": "Storage", "admin.general.configuration": "Configuration", @@ -431,6 +435,58 @@ "admin.reset_password.submit": "Please enter at least {chars} characters.", "admin.reset_password.titleReset": "Reset Password", "admin.reset_password.titleSwitch": "Switch Account to Email/Password", + "admin.saml.assertionConsumerServiceURLDesc": "Enter https://<your-mattermost-url>/login/sso/saml. Make sure you use HTTP or HTTPS in your URL depending on your server configuration. This field is also known as the Assertion Consumer Service URL.", + "admin.saml.assertionConsumerServiceURLEx": "Ex \"https://<your-mattermost-url>/login/sso/saml\"", + "admin.saml.assertionConsumerServiceURLTitle": "Service Provider Login URL:", + "admin.saml.emailAttrDesc": "The attribute in the SAML Assertion that will be used to populate the email addresses of users in Mattermost.", + "admin.saml.emailAttrEx": "Ex \"Email\" or \"PrimaryEmail\"", + "admin.saml.emailAttrTitle": "Email Attribute:", + "admin.saml.enableDescription": "When true, Mattermost allows login using SAML. Please see <a href='http://docs.mattermost.com/deployment/sso-saml.html' target='_blank'>documentation</a> to learn more about configuring SAML for Mattermost.", + "admin.saml.enableTitle": "Enable Login With SAML:", + "admin.saml.encryptDescription": "When true, Mattermost will decrypt SAML Assertions encrypted with your Service Provider Public Certificate.", + "admin.saml.encryptTitle": "Enable Encryption:", + "admin.saml.firstnameAttrDesc": "The attribute in the SAML Assertion that will be used to populate the first name of users in Mattermost.", + "admin.saml.firstnameAttrEx": "Ex \"FirstName\"", + "admin.saml.firstnameAttrTitle": "First Name Attribute:", + "admin.saml.idpCertificateFileDesc": "The public authentication certificate issued by your Identity Provider.", + "admin.saml.idpCertificateFileRemoveDesc": "Remove the public authentication certificate issued by your Identity Provider.", + "admin.saml.idpCertificateFileTitle": "Identity Provider Public Certificate:", + "admin.saml.idpDescriptorUrlDesc": "The issuer URL for the Identity Provider you use for SAML requests.", + "admin.saml.idpDescriptorUrlEx": "Ex \"https://idp.example.org/SAML2/issuer\"", + "admin.saml.idpDescriptorUrlTitle": "Identity Provider Issuer URL:", + "admin.saml.idpUrlDesc": "The URL where Mattermost sends a SAML request to start login sequence.", + "admin.saml.idpUrlEx": "Ex \"https://idp.example.org/SAML2/SSO/Login\"", + "admin.saml.idpUrlTitle": "SAML SSO URL:", + "admin.saml.lastnameAttrDesc": "The attribute in the SAML Assertion that will be used to populate the last name of users in Mattermost.", + "admin.saml.lastnameAttrEx": "Ex \"LastName\"", + "admin.saml.lastnameAttrTitle": "Last Name Attribute:", + "admin.saml.localeAttrDesc": "(Optional) The attribute in the SAML Assertion that will be used to populate the language of users in Mattermost.", + "admin.saml.localeAttrEx": "Ex \"Locale\" or \"PrimaryLanguage\"", + "admin.saml.localeAttrTitle": "Preferred Language Attribute:", + "admin.saml.loginButtonTextDesc": "(Optional) The text that appears in the login button on the login page. Defaults to \"With SAML\".", + "admin.saml.loginButtonTextEx": "Ex \"With OKTA\"", + "admin.saml.loginButtonTextTitle": "Login Button Text:", + "admin.saml.nicknameAttrDesc": "(Optional) The attribute in the SAML Assertion that will be used to populate the nickname of users in Mattermost.", + "admin.saml.nicknameAttrEx": "Ex \"Nickname\"", + "admin.saml.nicknameAttrTitle": "Nickname Attribute:", + "admin.saml.privateKeyFileFileDesc": "The private key used to decrypt SAML Assertions from the Identity Provider.", + "admin.saml.privateKeyFileFileRemoveDesc": "Remove the private key used to decrypt SAML Assertions from the Identity Provider.", + "admin.saml.privateKeyFileTitle": "Service Provider Private Key:", + "admin.saml.publicCertificateFileDesc": "The certificate used to generate the signature on a SAML request to the Identity Provider for a service provider initiated SAML login, when Mattermost is the Service Provider.", + "admin.saml.publicCertificateFileRemoveDesc": "Remove the certificate used to generate the signature on a SAML request to the Identity Provider for a service provider initiated SAML login, when Mattermost is the Service Provider.", + "admin.saml.publicCertificateFileTitle": "Service Provider Public Certificate:", + "admin.saml.remove.idp_certificate": "Remove Identity Provider Certificate", + "admin.saml.remove.privKey": "Remove Service Provider Private Key", + "admin.saml.remove.sp_certificate": "Remove Service Provider Certificate", + "admin.saml.removing.certificate": "Removing Certificate...", + "admin.saml.removing.privKey": "Removing Private Key...", + "admin.saml.uploading.certificate": "Uploading Certificate...", + "admin.saml.uploading.privateKey": "Uploading Private Key...", + "admin.saml.usernameAttrDesc": "The attribute in the SAML Assertion that will be used to populate the username field in Mattermost.", + "admin.saml.usernameAttrEx": "Ex \"Username\"", + "admin.saml.usernameAttrTitle": "Username Attribute:", + "admin.saml.verifyDescription": "When true, Mattermost verifies that the signature sent from the SAML Response matches the Service Provider Login URL", + "admin.saml.verifyTitle": "Verify Signature:", "admin.save": "Save", "admin.saving": "Saving Config...", "admin.security.connection": "Connections", @@ -522,6 +578,7 @@ "admin.sidebar.rateLimiting": "Rate Limiting", "admin.sidebar.reports": "REPORTING", "admin.sidebar.rmTeamSidebar": "Remove team from sidebar menu", + "admin.sidebar.saml": "SAML", "admin.sidebar.security": "Security", "admin.sidebar.sessions": "Sessions", "admin.sidebar.settings": "SETTINGS", @@ -842,7 +899,7 @@ "claim.ldap_to_email.title": "Switch LDAP Account to Email/Password", "claim.oauth_to_email.confirm": "Confirm Password", "claim.oauth_to_email.description": "Upon changing your account type, you will only be able to login with your email and password.", - "claim.oauth_to_email.enterNewPwd": "Enter a new password for your {site} account", + "claim.oauth_to_email.enterNewPwd": "Enter a new password for your {site} email account", "claim.oauth_to_email.enterPwd": "Please enter a password.", "claim.oauth_to_email.newPwd": "New Password", "claim.oauth_to_email.pwdNotMatch": "Password do not match.", @@ -1454,6 +1511,7 @@ "user.settings.general.emailHelp4": "A verification email was sent to {email}.", "user.settings.general.emailLdapCantUpdate": "Login occurs through LDAP. Email cannot be updated. Email address used for notifications is {email}.", "user.settings.general.emailMatch": "The new emails you entered do not match.", + "user.settings.general.emailSamlCantUpdate": "Login occurs through SAML. Email cannot be updated. Email address used for notifications is {email}.", "user.settings.general.emailUnchanged": "Your new email address is the same as your old email address.", "user.settings.general.emptyName": "Click 'Edit' to add your full name", "user.settings.general.emptyNickname": "Click 'Edit' to add a nickname", @@ -1465,6 +1523,7 @@ "user.settings.general.lastName": "Last Name", "user.settings.general.loginGitlab": "Login done through GitLab ({email})", "user.settings.general.loginLdap": "Login done through LDAP ({email})", + "user.settings.general.loginSaml": "Login done through SAML ({email})", "user.settings.general.newAddress": "New Address: {email}<br />Check your email to verify the above address.", "user.settings.general.nickname": "Nickname", "user.settings.general.nicknameExtra": "Use Nickname for a name you might be called that is different from your first name and username. This is most often used when two or more people have similar sounding names and usernames.", @@ -1552,10 +1611,12 @@ "user.settings.security.passwordLengthError": "New passwords must be at least {chars} characters", "user.settings.security.passwordMatchError": "The new passwords you entered do not match", "user.settings.security.retypePassword": "Retype New Password", + "user.settings.security.saml": "SAML", "user.settings.security.switchEmail": "Switch to using email and password", "user.settings.security.switchGitlab": "Switch to using GitLab SSO", "user.settings.security.switchGoogle": "Switch to using Google SSO", "user.settings.security.switchLdap": "Switch to using LDAP", + "user.settings.security.switchSaml": "Switch to using SAML SSO", "user.settings.security.title": "Security Settings", "user.settings.security.viewHistory": "View Access History", "user_list.notFound": "No users found", diff --git a/webapp/package.json b/webapp/package.json index 69d91e345..62e8b0bc1 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -18,7 +18,7 @@ "keymirror": "0.1.1", "marked": "mattermost/marked#12d2be4cdf54d4ec95fead934e18840b6a2c1a7b", "match-at": "0.1.0", - "mattermost": "mattermost/mattermost-javascript#8e4c320d5af653eacb248455d77057a76ec28830", + "mattermost": "mattermost/mattermost-javascript#798c39c5d302d2d109e768a35575ebdbf2a8ee6a", "object-assign": "4.1.0", "perfect-scrollbar": "0.6.11", "react": "15.0.2", diff --git a/webapp/routes/route_admin_console.jsx b/webapp/routes/route_admin_console.jsx index b088b430b..1f5e69c2d 100644 --- a/webapp/routes/route_admin_console.jsx +++ b/webapp/routes/route_admin_console.jsx @@ -15,6 +15,7 @@ import LogSettings from 'components/admin_console/log_settings.jsx'; import EmailAuthenticationSettings from 'components/admin_console/email_authentication_settings.jsx'; import GitLabSettings from 'components/admin_console/gitlab_settings.jsx'; import LdapSettings from 'components/admin_console/ldap_settings.jsx'; +import SamlSettings from 'components/admin_console/saml_settings.jsx'; import SignupSettings from 'components/admin_console/signup_settings.jsx'; import LoginSettings from 'components/admin_console/login_settings.jsx'; import PublicLinkSettings from 'components/admin_console/public_link_settings.jsx'; @@ -90,6 +91,10 @@ export default ( path='ldap' component={LdapSettings} /> + <Route + path='saml' + component={SamlSettings} + /> </Route> <Route path='security'> <IndexRedirect to='sign_up'/> diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index 4dba1558c..a1b2d772d 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -132,6 +132,12 @@ .btn { font-size: 13px; } + + &.remove-filename { + margin-bottom: 5px; + top: -2px; + position: relative; + } } .alert { diff --git a/webapp/sass/routes/_signup.scss b/webapp/sass/routes/_signup.scss index 4dc0dce42..804e4c890 100644 --- a/webapp/sass/routes/_signup.scss +++ b/webapp/sass/routes/_signup.scss @@ -280,6 +280,18 @@ } } + &.saml { + background: #dd4b39; + + &:hover { + background: darken(#dd4b39, 10%); + } + + span { + vertical-align: middle; + } + } + &.btn-full { padding-left: 35px; text-align: left; diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 0934e8de9..1b0fa6374 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -227,6 +227,7 @@ export default { GOOGLE_SERVICE: 'google', EMAIL_SERVICE: 'email', LDAP_SERVICE: 'ldap', + SAML_SERVICE: 'saml', USERNAME_SERVICE: 'username', SIGNIN_CHANGE: 'signin_change', PASSWORD_CHANGE: 'password_change', |