summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorenahum <nahumhbl@gmail.com>2016-07-05 15:49:00 -0400
committerGitHub <noreply@github.com>2016-07-05 15:49:00 -0400
commit5f04dc4f45b9657d46380499f92ae6e5c1bf5506 (patch)
tree86670cb20e038f9716eca3ab9dcb7f2f71263286
parentf91b9d4a654ff27777580651d853b6372a425af6 (diff)
downloadchat-5f04dc4f45b9657d46380499f92ae6e5c1bf5506.tar.gz
chat-5f04dc4f45b9657d46380499f92ae6e5c1bf5506.tar.bz2
chat-5f04dc4f45b9657d46380499f92ae6e5c1bf5506.zip
SAML support (#3494)
* PLT-3073: Implement SAML/Okta Server side (EE) (#3422) * PLT-3137 Support for SAML configuration * PLT-3410 SAML Database Store * PLT-3411 CLI to add Identity Provider Certificate and Service Provider Private Key * PLT-3409 SAML Interface for EE * PLT-3139 Handle SAML authentication server side * Add localization messages * PLT-3443 SAML Obtain SP metadata * PLT-3142 Login & Switch to/from SAML * Remove Certs for Database & Clean SAML Request * Make required Username, FirstName and LastName * PLT-3140 Add SAML to System Console (#3476) * PLT-3140 Add SAML to System Console * Move web_client functions to client.jsx * Fix issues found by PM * update package.json mattermost driver * Fix text messages for SAML
-rw-r--r--api/admin.go77
-rw-r--r--api/authentication.go7
-rw-r--r--api/context.go7
-rw-r--r--api/user.go106
-rw-r--r--config/config.json18
-rw-r--r--einterfaces/saml.go25
-rw-r--r--glide.lock2
-rw-r--r--glide.yaml1
-rw-r--r--i18n/en.json148
-rw-r--r--model/config.go156
-rw-r--r--model/license.go6
-rw-r--r--model/saml.go18
-rw-r--r--utils/config.go9
-rw-r--r--utils/license.go1
-rw-r--r--vendor/github.com/kardianos/osext/LICENSE27
-rw-r--r--vendor/github.com/kardianos/osext/README.md16
-rw-r--r--vendor/github.com/kardianos/osext/osext.go33
-rw-r--r--vendor/github.com/kardianos/osext/osext_plan9.go20
-rw-r--r--vendor/github.com/kardianos/osext/osext_procfs.go36
-rw-r--r--vendor/github.com/kardianos/osext/osext_sysctl.go126
-rw-r--r--vendor/github.com/kardianos/osext/osext_test.go203
-rw-r--r--vendor/github.com/kardianos/osext/osext_windows.go34
-rw-r--r--webapp/components/admin_console/admin_sidebar.jsx16
-rw-r--r--webapp/components/admin_console/file_upload_setting.jsx124
-rw-r--r--webapp/components/admin_console/remove_file_setting.jsx72
-rw-r--r--webapp/components/admin_console/saml_settings.jsx518
-rw-r--r--webapp/components/admin_console/user_item.jsx4
-rw-r--r--webapp/components/claim/components/email_to_oauth.jsx8
-rw-r--r--webapp/components/claim/components/oauth_to_email.jsx5
-rw-r--r--webapp/components/login/login_controller.jsx18
-rw-r--r--webapp/components/signup_user_complete.jsx14
-rw-r--r--webapp/components/user_settings/user_settings_general.jsx28
-rw-r--r--webapp/components/user_settings/user_settings_security.jsx27
-rw-r--r--webapp/i18n/en.json63
-rw-r--r--webapp/package.json2
-rw-r--r--webapp/routes/route_admin_console.jsx5
-rw-r--r--webapp/sass/routes/_admin-console.scss6
-rw-r--r--webapp/sass/routes/_signup.scss12
-rw-r--r--webapp/utils/constants.jsx1
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',