summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/channel_test.go20
-rw-r--r--api/context.go66
-rw-r--r--api/oauth.go2
-rw-r--r--api/team_test.go4
-rw-r--r--api/user.go59
-rw-r--r--api/user_test.go2
-rw-r--r--config/config.json1
-rw-r--r--i18n/en.json24
-rw-r--r--model/config.go6
-rw-r--r--store/sql_user_store.go6
-rw-r--r--store/sql_user_store_test.go2
-rw-r--r--store/store.go2
-rw-r--r--templates/mfa_change_body.html43
-rw-r--r--utils/config.go1
-rw-r--r--webapp/actions/user_actions.jsx43
-rw-r--r--webapp/client/client.jsx15
-rw-r--r--webapp/client/web_client.jsx5
-rw-r--r--webapp/components/admin_console/admin_sidebar.jsx16
-rw-r--r--webapp/components/admin_console/mfa_settings.jsx99
-rw-r--r--webapp/components/admin_console/password_settings.jsx32
-rw-r--r--webapp/components/claim/components/email_to_ldap.jsx81
-rw-r--r--webapp/components/claim/components/email_to_oauth.jsx73
-rw-r--r--webapp/components/claim/components/ldap_to_email.jsx64
-rw-r--r--webapp/components/login/components/login_mfa.jsx2
-rw-r--r--webapp/components/mfa/components/confirm.jsx75
-rw-r--r--webapp/components/mfa/components/setup.jsx156
-rw-r--r--webapp/components/mfa/mfa_controller.jsx66
-rw-r--r--webapp/components/user_settings/user_settings_security.jsx168
-rw-r--r--webapp/i18n/en.json25
-rw-r--r--webapp/routes/route_admin_console.jsx5
-rw-r--r--webapp/routes/route_mfa.jsx24
-rw-r--r--webapp/routes/route_root.jsx30
-rw-r--r--webapp/sass/base/_structure.scss4
-rw-r--r--webapp/tests/client_user.test.jsx3
34 files changed, 997 insertions, 227 deletions
diff --git a/api/channel_test.go b/api/channel_test.go
index 2414b51e2..11914414b 100644
--- a/api/channel_test.go
+++ b/api/channel_test.go
@@ -95,18 +95,22 @@ func TestCreateChannel(t *testing.T) {
}
isLicensed := utils.IsLicensed
+ license := utils.License
restrictPublicChannel := *utils.Cfg.TeamSettings.RestrictPublicChannelManagement
restrictPrivateChannel := *utils.Cfg.TeamSettings.RestrictPrivateChannelManagement
defer func() {
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = restrictPublicChannel
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = restrictPrivateChannel
utils.IsLicensed = isLicensed
+ utils.License = license
utils.SetDefaultRolesBasedOnConfig()
}()
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = model.PERMISSIONS_ALL
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = model.PERMISSIONS_ALL
utils.SetDefaultRolesBasedOnConfig()
utils.IsLicensed = true
+ utils.License = &model.License{Features: &model.Features{}}
+ utils.License.Features.SetDefaults()
channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel3 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
@@ -288,17 +292,21 @@ func TestUpdateChannel(t *testing.T) {
}
isLicensed := utils.IsLicensed
+ license := utils.License
restrictPublicChannel := *utils.Cfg.TeamSettings.RestrictPublicChannelManagement
restrictPrivateChannel := *utils.Cfg.TeamSettings.RestrictPrivateChannelManagement
defer func() {
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = restrictPublicChannel
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = restrictPrivateChannel
utils.IsLicensed = isLicensed
+ utils.License = license
utils.SetDefaultRolesBasedOnConfig()
}()
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = model.PERMISSIONS_ALL
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = model.PERMISSIONS_ALL
utils.IsLicensed = true
+ utils.License = &model.License{Features: &model.Features{}}
+ utils.License.Features.SetDefaults()
utils.SetDefaultRolesBasedOnConfig()
channel2 := th.CreateChannel(Client, team)
@@ -436,17 +444,21 @@ func TestUpdateChannelHeader(t *testing.T) {
}
isLicensed := utils.IsLicensed
+ license := utils.License
restrictPublicChannel := *utils.Cfg.TeamSettings.RestrictPublicChannelManagement
restrictPrivateChannel := *utils.Cfg.TeamSettings.RestrictPrivateChannelManagement
defer func() {
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = restrictPublicChannel
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = restrictPrivateChannel
utils.IsLicensed = isLicensed
+ utils.License = license
utils.SetDefaultRolesBasedOnConfig()
}()
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = model.PERMISSIONS_ALL
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = model.PERMISSIONS_ALL
utils.IsLicensed = true
+ utils.License = &model.License{Features: &model.Features{}}
+ utils.License.Features.SetDefaults()
utils.SetDefaultRolesBasedOnConfig()
th.LoginBasic()
@@ -566,17 +578,21 @@ func TestUpdateChannelPurpose(t *testing.T) {
}
isLicensed := utils.IsLicensed
+ license := utils.License
restrictPublicChannel := *utils.Cfg.TeamSettings.RestrictPublicChannelManagement
restrictPrivateChannel := *utils.Cfg.TeamSettings.RestrictPrivateChannelManagement
defer func() {
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = restrictPublicChannel
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = restrictPrivateChannel
utils.IsLicensed = isLicensed
+ utils.License = license
utils.SetDefaultRolesBasedOnConfig()
}()
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = model.PERMISSIONS_ALL
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = model.PERMISSIONS_ALL
utils.IsLicensed = true
+ utils.License = &model.License{Features: &model.Features{}}
+ utils.License.Features.SetDefaults()
utils.SetDefaultRolesBasedOnConfig()
th.LoginBasic()
@@ -1071,17 +1087,21 @@ func TestDeleteChannel(t *testing.T) {
}
isLicensed := utils.IsLicensed
+ license := utils.License
restrictPublicChannel := *utils.Cfg.TeamSettings.RestrictPublicChannelManagement
restrictPrivateChannel := *utils.Cfg.TeamSettings.RestrictPrivateChannelManagement
defer func() {
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = restrictPublicChannel
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = restrictPrivateChannel
utils.IsLicensed = isLicensed
+ utils.License = license
utils.SetDefaultRolesBasedOnConfig()
}()
*utils.Cfg.TeamSettings.RestrictPublicChannelManagement = model.PERMISSIONS_ALL
*utils.Cfg.TeamSettings.RestrictPrivateChannelManagement = model.PERMISSIONS_ALL
utils.IsLicensed = true
+ utils.License = &model.License{Features: &model.Features{}}
+ utils.License.Features.SetDefaults()
utils.SetDefaultRolesBasedOnConfig()
th.LoginSystemAdmin()
diff --git a/api/context.go b/api/context.go
index 4c2e9d489..1e82acb68 100644
--- a/api/context.go
+++ b/api/context.go
@@ -47,51 +47,55 @@ type Context struct {
}
func ApiAppHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, false, false, true, false, false, false}
+ return &handler{h, false, false, true, false, false, false, false}
}
func AppHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, false, false, false, false, false, false}
+ return &handler{h, false, false, false, false, false, false, false}
}
func AppHandlerIndependent(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, false, false, false, false, true, false}
+ return &handler{h, false, false, false, false, true, false, false}
}
func ApiUserRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, true, false, true, false, false, false}
+ return &handler{h, true, false, true, false, false, false, true}
}
func ApiUserRequiredActivity(h func(*Context, http.ResponseWriter, *http.Request), isUserActivity bool) http.Handler {
- return &handler{h, true, false, true, isUserActivity, false, false}
+ return &handler{h, true, false, true, isUserActivity, false, false, true}
+}
+
+func ApiUserRequiredMfa(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
+ return &handler{h, true, false, true, false, false, false, false}
}
func UserRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, true, false, false, false, false, false}
+ return &handler{h, true, false, false, false, false, false, true}
}
func AppHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, false, false, false, false, false, true}
+ return &handler{h, false, false, false, false, false, true, false}
}
func ApiAdminSystemRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, true, true, true, false, false, false}
+ return &handler{h, true, true, true, false, false, false, true}
}
func ApiAdminSystemRequiredTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, true, true, true, false, false, true}
+ return &handler{h, true, true, true, false, false, true, true}
}
func ApiAppHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, false, false, true, false, false, true}
+ return &handler{h, false, false, true, false, false, true, false}
}
func ApiUserRequiredTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, true, false, true, false, false, true}
+ return &handler{h, true, false, true, false, false, true, true}
}
func ApiAppHandlerTrustRequesterIndependent(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
- return &handler{h, false, false, true, false, true, true}
+ return &handler{h, false, false, true, false, true, true, false}
}
type handler struct {
@@ -102,6 +106,7 @@ type handler struct {
isUserActivity bool
isTeamIndependent bool
trustRequester bool
+ requireMfa bool
}
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -204,6 +209,10 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.UserRequired()
}
+ if c.Err == nil && h.requireMfa {
+ c.MfaRequired()
+ }
+
if c.Err == nil && h.requireSystemAdmin {
c.SystemAdminRequired()
}
@@ -331,6 +340,39 @@ func (c *Context) UserRequired() {
}
}
+func (c *Context) MfaRequired() {
+ // Must be licensed for MFA and have it configured for enforcement
+ if !utils.IsLicensed || !*utils.License.Features.MFA || !*utils.Cfg.ServiceSettings.EnableMultifactorAuthentication || !*utils.Cfg.ServiceSettings.EnforceMultifactorAuthentication {
+ return
+ }
+
+ // OAuth integrations are excepted
+ if c.Session.IsOAuth {
+ return
+ }
+
+ if result := <-Srv.Store.User().Get(c.Session.UserId); result.Err != nil {
+ c.Err = model.NewLocAppError("", "api.context.session_expired.app_error", nil, "MfaRequired")
+ c.Err.StatusCode = http.StatusUnauthorized
+ return
+ } else {
+ user := result.Data.(*model.User)
+
+ // Only required for email and ldap accounts
+ if user.AuthService != "" &&
+ user.AuthService != model.USER_AUTH_SERVICE_EMAIL &&
+ user.AuthService != model.USER_AUTH_SERVICE_LDAP {
+ return
+ }
+
+ if !user.MfaActive {
+ c.Err = model.NewLocAppError("", "api.context.mfa_required.app_error", nil, "MfaRequired")
+ c.Err.StatusCode = http.StatusUnauthorized
+ return
+ }
+ }
+}
+
func (c *Context) SystemAdminRequired() {
if len(c.Session.UserId) == 0 {
c.Err = model.NewLocAppError("", "api.context.session_expired.app_error", nil, "SystemAdminRequired")
diff --git a/api/oauth.go b/api/oauth.go
index 08c763b61..268cf1aed 100644
--- a/api/oauth.go
+++ b/api/oauth.go
@@ -856,7 +856,7 @@ func CompleteSwitchWithOAuth(c *Context, w http.ResponseWriter, r *http.Request,
return
}
- if result := <-Srv.Store.User().UpdateAuthData(user.Id, service, &authData, ssoEmail); result.Err != nil {
+ if result := <-Srv.Store.User().UpdateAuthData(user.Id, service, &authData, ssoEmail, true); result.Err != nil {
c.Err = result.Err
return
}
diff --git a/api/team_test.go b/api/team_test.go
index 5880ffcff..2af46f4dc 100644
--- a/api/team_test.go
+++ b/api/team_test.go
@@ -426,10 +426,14 @@ func TestInviteMembers(t *testing.T) {
}
isLicensed := utils.IsLicensed
+ license := utils.License
defer func() {
utils.IsLicensed = isLicensed
+ utils.License = license
}()
utils.IsLicensed = true
+ utils.License = &model.License{Features: &model.Features{}}
+ utils.License.Features.SetDefaults()
if _, err := Client.InviteMembers(invites); err == nil {
t.Fatal("should have errored not team admin and licensed")
diff --git a/api/user.go b/api/user.go
index 2085ccb60..0e74a577c 100644
--- a/api/user.go
+++ b/api/user.go
@@ -65,8 +65,8 @@ func InitUser() {
BaseRoutes.NeedChannel.Handle("/users/autocomplete", ApiUserRequired(autocompleteUsersInChannel)).Methods("GET")
BaseRoutes.Users.Handle("/mfa", ApiAppHandler(checkMfa)).Methods("POST")
- BaseRoutes.Users.Handle("/generate_mfa_secret", ApiUserRequiredTrustRequester(generateMfaSecret)).Methods("GET")
- BaseRoutes.Users.Handle("/update_mfa", ApiUserRequired(updateMfa)).Methods("POST")
+ BaseRoutes.Users.Handle("/generate_mfa_secret", ApiUserRequiredMfa(generateMfaSecret)).Methods("GET")
+ BaseRoutes.Users.Handle("/update_mfa", ApiUserRequiredMfa(updateMfa)).Methods("POST")
BaseRoutes.Users.Handle("/claim/email_to_oauth", ApiAppHandler(emailToOAuth)).Methods("POST")
BaseRoutes.Users.Handle("/claim/oauth_to_email", ApiUserRequired(oauthToEmail)).Methods("POST")
@@ -1875,6 +1875,30 @@ func sendPasswordChangeEmail(c *Context, email, siteURL, method string) {
}
}
+func sendMfaChangeEmail(c *Context, email string, siteURL string, activated bool) {
+ subject := c.T("api.templates.mfa_change_subject",
+ map[string]interface{}{"SiteName": utils.Cfg.TeamSettings.SiteName})
+
+ bodyPage := utils.NewHTMLTemplate("mfa_change_body", c.Locale)
+ bodyPage.Props["SiteURL"] = siteURL
+
+ bodyText := ""
+ if activated {
+ bodyText = "api.templates.mfa_activated_body.info"
+ bodyPage.Props["Title"] = c.T("api.templates.mfa_activated_body.title")
+ } else {
+ bodyText = "api.templates.mfa_deactivated_body.info"
+ bodyPage.Props["Title"] = c.T("api.templates.mfa_deactivated_body.title")
+ }
+
+ bodyPage.Html["Info"] = template.HTML(c.T(bodyText,
+ map[string]interface{}{"SiteURL": siteURL}))
+
+ if err := utils.SendMail(email, subject, bodyPage.Render()); err != nil {
+ l4g.Error(utils.T("api.user.send_mfa_change_email.error"), err)
+ }
+}
+
func sendEmailChangeEmail(c *Context, oldEmail, newEmail, siteURL string) {
subject := fmt.Sprintf("[%v] %v", utils.Cfg.TeamSettings.SiteName, c.T("api.templates.email_change_subject",
map[string]interface{}{"TeamDisplayName": utils.Cfg.TeamSettings.SiteName}))
@@ -2016,6 +2040,8 @@ func emailToOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ mfaToken := props["token"]
+
service := props["service"]
if len(service) == 0 {
c.SetInvalidParam("emailToOAuth", "service")
@@ -2039,7 +2065,7 @@ func emailToOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
user = result.Data.(*model.User)
}
- if err := checkPasswordAndAllCriteria(user, password, ""); err != nil {
+ if err := checkPasswordAndAllCriteria(user, password, mfaToken); err != nil {
c.LogAuditWithUserId(user.Id, "failed - bad authentication")
c.Err = err
return
@@ -2147,6 +2173,8 @@ func emailToLdap(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ token := props["token"]
+
c.LogAudit("attempt")
var user *model.User
@@ -2158,7 +2186,7 @@ func emailToLdap(c *Context, w http.ResponseWriter, r *http.Request) {
user = result.Data.(*model.User)
}
- if err := checkPasswordAndAllCriteria(user, emailPassword, ""); err != nil {
+ if err := checkPasswordAndAllCriteria(user, emailPassword, token); err != nil {
c.LogAuditWithUserId(user.Id, "failed - bad authentication")
c.Err = err
return
@@ -2213,6 +2241,8 @@ func ldapToEmail(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ token := props["token"]
+
c.LogAudit("attempt")
var user *model.User
@@ -2242,6 +2272,12 @@ func ldapToEmail(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ if err := checkUserMfa(user, token); err != nil {
+ c.LogAuditWithUserId(user.Id, "fail - mfa token failed")
+ c.Err = err
+ return
+ }
+
if result := <-Srv.Store.User().UpdatePassword(user.Id, model.HashPassword(emailPassword)); result.Err != nil {
c.LogAudit("fail - database issue")
c.Err = result.Err
@@ -2379,18 +2415,33 @@ func updateMfa(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+ c.LogAudit("attempt")
+
if activate {
if err := ActivateMfa(c.Session.UserId, token); err != nil {
c.Err = err
return
}
+ c.LogAudit("success - activated")
} else {
if err := DeactivateMfa(c.Session.UserId); err != nil {
c.Err = err
return
}
+ c.LogAudit("success - deactivated")
}
+ go func() {
+ var user *model.User
+ if result := <-Srv.Store.User().Get(c.Session.UserId); result.Err != nil {
+ l4g.Warn(result.Err)
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ sendMfaChangeEmail(c, user.Email, c.GetSiteURL(), activate)
+ }()
+
rdata := map[string]string{}
rdata["status"] = "ok"
w.Write([]byte(model.MapToJson(rdata)))
diff --git a/api/user_test.go b/api/user_test.go
index 85af7e598..65bdcb653 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -1341,7 +1341,7 @@ func TestResetPassword(t *testing.T) {
}
authData := model.NewId()
- if result := <-Srv.Store.User().UpdateAuthData(user.Id, "random", &authData, ""); result.Err != nil {
+ if result := <-Srv.Store.User().UpdateAuthData(user.Id, "random", &authData, "", true); result.Err != nil {
t.Fatal(result.Err)
}
diff --git a/config/config.json b/config/config.json
index ba6973858..a51e8f60e 100644
--- a/config/config.json
+++ b/config/config.json
@@ -25,6 +25,7 @@
"EnableSecurityFixAlert": true,
"EnableInsecureOutgoingConnections": false,
"EnableMultifactorAuthentication": false,
+ "EnforceMultifactorAuthentication": false,
"AllowCorsFrom": "",
"SessionLengthWebInDays": 30,
"SessionLengthMobileInDays": 30,
diff --git a/i18n/en.json b/i18n/en.json
index 84d5be7de..37acda939 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -712,6 +712,10 @@
"translation": "Invalid or expired session, please login again."
},
{
+ "id": "api.context.mfa_required.app_error",
+ "translation": "Multi-factor authentication is required on this server."
+ },
+ {
"id": "api.context.system_permissions.app_error",
"translation": "You do not have the appropriate permissions (system)"
},
@@ -1988,6 +1992,26 @@
"translation": "Your password has been updated for {{.TeamDisplayName}} on {{ .SiteName }}"
},
{
+ "id": "api.templates.mfa_activated_body.info",
+ "translation": "Multi-factor authentication has been added to your account on {{ .SiteURL }}.<br>If this change wasn't initiated by you, please contact your system administrator."
+ },
+ {
+ "id": "api.templates.mfa_deactivated_body.info",
+ "translation": "Multi-factor authentication has been removed from your account on {{ .SiteURL }}.<br>If this change wasn't initiated by you, please contact your system administrator."
+ },
+ {
+ "id": "api.templates.mfa_activated_body.title",
+ "translation": "Multi-factor authentication was added"
+ },
+ {
+ "id": "api.templates.mfa_deactivated_body.title",
+ "translation": "Multi-factor authentication was removed"
+ },
+ {
+ "id": "api.templates.mfa_change_subject",
+ "translation": "Your MFA has been updated on {{ .SiteName }}"
+ },
+ {
"id": "api.templates.post_body.button",
"translation": "Go To Post"
},
diff --git a/model/config.go b/model/config.go
index 6288a036b..7d3cb93d6 100644
--- a/model/config.go
+++ b/model/config.go
@@ -80,6 +80,7 @@ type ServiceSettings struct {
EnableSecurityFixAlert *bool
EnableInsecureOutgoingConnections *bool
EnableMultifactorAuthentication *bool
+ EnforceMultifactorAuthentication *bool
AllowCorsFrom *string
SessionLengthWebInDays *int
SessionLengthMobileInDays *int
@@ -434,6 +435,11 @@ func (o *Config) SetDefaults() {
*o.ServiceSettings.EnableMultifactorAuthentication = false
}
+ if o.ServiceSettings.EnforceMultifactorAuthentication == nil {
+ o.ServiceSettings.EnforceMultifactorAuthentication = new(bool)
+ *o.ServiceSettings.EnforceMultifactorAuthentication = false
+ }
+
if o.PasswordSettings.MinimumLength == nil {
o.PasswordSettings.MinimumLength = new(int)
*o.PasswordSettings.MinimumLength = PASSWORD_MINIMUM_LENGTH
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 3fddfb77d..286b6551a 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -275,7 +275,7 @@ func (us SqlUserStore) UpdateFailedPasswordAttempts(userId string, attempts int)
return storeChannel
}
-func (us SqlUserStore) UpdateAuthData(userId string, service string, authData *string, email string) StoreChannel {
+func (us SqlUserStore) UpdateAuthData(userId string, service string, authData *string, email string, resetMfa bool) StoreChannel {
storeChannel := make(StoreChannel, 1)
@@ -301,6 +301,10 @@ func (us SqlUserStore) UpdateAuthData(userId string, service string, authData *s
query += ", Email = :Email"
}
+ if resetMfa {
+ query += ", MfaActive = false, MfaSecret = ''"
+ }
+
query += " WHERE Id = :UserId"
if _, err := us.GetMaster().Exec(query, map[string]interface{}{"LastPasswordUpdate": updateAt, "UpdateAt": updateAt, "UserId": userId, "AuthService": service, "AuthData": authData, "Email": email}); err != nil {
diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go
index acd87a036..56d9c0a6a 100644
--- a/store/sql_user_store_test.go
+++ b/store/sql_user_store_test.go
@@ -756,7 +756,7 @@ func TestUserStoreUpdateAuthData(t *testing.T) {
service := "someservice"
authData := model.NewId()
- if err := (<-store.User().UpdateAuthData(u1.Id, service, &authData, "")).Err; err != nil {
+ if err := (<-store.User().UpdateAuthData(u1.Id, service, &authData, "", true)).Err; err != nil {
t.Fatal(err)
}
diff --git a/store/store.go b/store/store.go
index 0f9b20ed8..ffc325eea 100644
--- a/store/store.go
+++ b/store/store.go
@@ -143,7 +143,7 @@ type UserStore interface {
UpdateLastPictureUpdate(userId string) StoreChannel
UpdateUpdateAt(userId string) StoreChannel
UpdatePassword(userId, newPassword string) StoreChannel
- UpdateAuthData(userId string, service string, authData *string, email string) StoreChannel
+ UpdateAuthData(userId string, service string, authData *string, email string, resetMfa bool) StoreChannel
UpdateMfaSecret(userId, secret string) StoreChannel
UpdateMfaActive(userId string, active bool) StoreChannel
Get(id string) StoreChannel
diff --git a/templates/mfa_change_body.html b/templates/mfa_change_body.html
new file mode 100644
index 000000000..b7cc0630c
--- /dev/null
+++ b/templates/mfa_change_body.html
@@ -0,0 +1,43 @@
+{{define "mfa_change_body"}}
+
+<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-top: 20px; line-height: 1.7; color: #555;">
+ <tr>
+ <td>
+ <table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 660px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF;">
+ <tr>
+ <td style="border: 1px solid #ddd;">
+ <table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;">
+ <tr>
+ <td style="padding: 20px 20px 10px; text-align:left;">
+ <img src="{{.Props.SiteURL}}/static/images/logo-email.png" width="130px" style="opacity: 0.5" alt="">
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <table border="0" cellpadding="0" cellspacing="0" style="padding: 20px 50px 0; text-align: center; margin: 0 auto">
+ <tr>
+ <td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;">
+ <h2 style="font-weight: normal; margin-top: 10px;">{{.Props.Title}}</h2>
+ <p>{{.Html.Info}}</p>
+ </td>
+ </tr>
+ <tr>
+ {{template "email_info" . }}
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ {{template "email_footer" . }}
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+</table>
+
+{{end}}
+
+
diff --git a/utils/config.go b/utils/config.go
index 7889ea9ac..c06223e6c 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -311,6 +311,7 @@ func getClientConfig(c *model.Config) map[string]string {
if *License.Features.MFA {
props["EnableMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnableMultifactorAuthentication)
+ props["EnforceMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnforceMultifactorAuthentication)
}
if *License.Features.Compliance {
diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx
index 6f19e9ace..812bc2716 100644
--- a/webapp/actions/user_actions.jsx
+++ b/webapp/actions/user_actions.jsx
@@ -16,10 +16,11 @@ import Client from 'client/web_client.jsx';
import {ActionTypes, Preferences} from 'utils/constants.jsx';
-export function switchFromLdapToEmail(email, password, ldapPassword, onSuccess, onError) {
+export function switchFromLdapToEmail(email, password, token, ldapPassword, onSuccess, onError) {
Client.ldapToEmail(
email,
password,
+ token,
ldapPassword,
(data) => {
if (data.follow_link) {
@@ -391,3 +392,43 @@ export function updateUserRoles(userId, newRoles, success, error) {
}
);
}
+
+export function activateMfa(code, success, error) {
+ Client.updateMfa(
+ code,
+ true,
+ () => {
+ AsyncClient.getMe();
+
+ if (success) {
+ success();
+ }
+ },
+ (err) => {
+ if (error) {
+ error(err);
+ }
+ }
+ );
+}
+
+export function checkMfa(loginId, success, error) {
+ if (global.window.mm_config.EnableMultifactorAuthentication !== 'true') {
+ success(false);
+ return;
+ }
+
+ Client.checkMfa(
+ loginId,
+ (data) => {
+ if (success) {
+ success(data.mfa_required === 'true');
+ }
+ },
+ (err) => {
+ if (error) {
+ error(err);
+ }
+ }
+ );
+}
diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx
index 88f910d46..c2db8a275 100644
--- a/webapp/client/client.jsx
+++ b/webapp/client/client.jsx
@@ -840,18 +840,13 @@ export default class Client {
this.track('api', 'api_users_reset_password');
}
- emailToOAuth(email, password, service, success, error) {
- var data = {};
- data.password = password;
- data.email = email;
- data.service = service;
-
+ emailToOAuth(email, password, token, service, success, error) {
request.
post(`${this.getUsersRoute()}/claim/email_to_oauth`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
- send(data).
+ send({password, email, token, service}).
end(this.handleResponse.bind(this, 'emailToOAuth', success, error));
this.track('api', 'api_users_email_to_oauth');
@@ -873,12 +868,13 @@ export default class Client {
this.track('api', 'api_users_oauth_to_email');
}
- emailToLdap(email, password, ldapId, ldapPassword, success, error) {
+ emailToLdap(email, password, token, ldapId, ldapPassword, success, error) {
var data = {};
data.email_password = password;
data.email = email;
data.ldap_id = ldapId;
data.ldap_password = ldapPassword;
+ data.token = token;
request.
post(`${this.getUsersRoute()}/claim/email_to_ldap`).
@@ -891,11 +887,12 @@ export default class Client {
this.track('api', 'api_users_email_to_ldap');
}
- ldapToEmail(email, emailPassword, ldapPassword, success, error) {
+ ldapToEmail(email, emailPassword, token, ldapPassword, success, error) {
var data = {};
data.email = email;
data.ldap_password = ldapPassword;
data.email_password = emailPassword;
+ data.token = token;
request.
post(`${this.getUsersRoute()}/claim/ldap_to_email`).
diff --git a/webapp/client/web_client.jsx b/webapp/client/web_client.jsx
index 62870c5bc..324d4cd25 100644
--- a/webapp/client/web_client.jsx
+++ b/webapp/client/web_client.jsx
@@ -38,6 +38,11 @@ class WebClientClass extends Client {
}
handleError(err, res) {
+ if (res.body.id === 'api.context.mfa_required.app_error') {
+ window.location.reload();
+ return;
+ }
+
if (err.status === HTTP_UNAUTHORIZED && res.req.url !== '/api/v3/users/login') {
GlobalActions.emitUserLoggedOutEvent('/login');
}
diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx
index 25a06cecf..2b304f11d 100644
--- a/webapp/components/admin_console/admin_sidebar.jsx
+++ b/webapp/components/admin_console/admin_sidebar.jsx
@@ -194,6 +194,7 @@ export default class AdminSidebar extends React.Component {
let clusterSettings = null;
let metricsSettings = null;
let complianceSettings = null;
+ let mfaSettings = null;
let license = null;
let audits = null;
@@ -284,6 +285,20 @@ export default class AdminSidebar extends React.Component {
);
}
+ if (global.window.mm_license.MFA === 'true') {
+ mfaSettings = (
+ <AdminSidebarSection
+ name='mfa'
+ title={
+ <FormattedMessage
+ id='admin.sidebar.mfa'
+ defaultMessage='MFA'
+ />
+ }
+ />
+ );
+ }
+
oauthSettings = (
<AdminSidebarSection
name='oauth'
@@ -507,6 +522,7 @@ export default class AdminSidebar extends React.Component {
{oauthSettings}
{ldapSettings}
{samlSettings}
+ {mfaSettings}
</AdminSidebarSection>
<AdminSidebarSection
name='security'
diff --git a/webapp/components/admin_console/mfa_settings.jsx b/webapp/components/admin_console/mfa_settings.jsx
new file mode 100644
index 000000000..df6346fe4
--- /dev/null
+++ b/webapp/components/admin_console/mfa_settings.jsx
@@ -0,0 +1,99 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import AdminSettings from './admin_settings.jsx';
+import SettingsGroup from './settings_group.jsx';
+import BooleanSetting from './boolean_setting.jsx';
+
+import React from 'react';
+import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
+
+export default class MfaSettings extends AdminSettings {
+ constructor(props) {
+ super(props);
+
+ this.getConfigFromState = this.getConfigFromState.bind(this);
+
+ this.renderSettings = this.renderSettings.bind(this);
+
+ this.state = Object.assign(this.state, {
+ enableMultifactorAuthentication: props.config.ServiceSettings.EnableMultifactorAuthentication,
+ enforceMultifactorAuthentication: props.config.ServiceSettings.EnforceMultifactorAuthentication
+ });
+ }
+
+ getConfigFromState(config) {
+ config.ServiceSettings.EnableMultifactorAuthentication = this.state.enableMultifactorAuthentication;
+ config.ServiceSettings.EnforceMultifactorAuthentication = this.state.enableMultifactorAuthentication && this.state.enforceMultifactorAuthentication;
+
+ return config;
+ }
+
+ getStateFromConfig(config) {
+ return {
+ enableMultifactorAuthentication: config.ServiceSettings.EnableMultifactorAuthentication,
+ enforceMultifactorAuthentication: config.ServiceSettings.EnableMultifactorAuthentication && config.ServiceSettings.EnforceMultifactorAuthentication
+ };
+ }
+
+ renderTitle() {
+ return (
+ <h3>
+ <FormattedMessage
+ id='admin.mfa.title'
+ defaultMessage='Multi-factor Authentication'
+ />
+ </h3>
+ );
+ }
+
+ renderSettings() {
+ return (
+ <SettingsGroup>
+ <div className='banner'>
+ <div className='banner__content'>
+ <FormattedMessage
+ id='admin.mfa.bannerDesc'
+ defaultMessage='Multi-factor authentication is only available for accounts with LDAP and email login methods. If there are users on your system with other login methods, it is recommended you set up multi-factor authentication directly with the SSO or SAML provider.'
+ />
+ </div>
+ </div>
+ <BooleanSetting
+ id='enableMultifactorAuthentication'
+ label={
+ <FormattedMessage
+ id='admin.service.mfaTitle'
+ defaultMessage='Enable Multi-factor Authentication:'
+ />
+ }
+ helpText={
+ <FormattedMessage
+ id='admin.service.mfaDesc'
+ defaultMessage='When true, users will be given the option to add multi-factor authentication to their account. They will need a smartphone and an authenticator app such as Google Authenticator.'
+ />
+ }
+ value={this.state.enableMultifactorAuthentication}
+ onChange={this.handleChange}
+ />
+ <BooleanSetting
+ id='enforceMultifactorAuthentication'
+ label={
+ <FormattedMessage
+ id='admin.service.enforceMfaTitle'
+ defaultMessage='Enforce Multi-factor Authentication:'
+ />
+ }
+ helpText={
+ <FormattedHTMLMessage
+ id='admin.service.enforceMfaDesc'
+ defaultMessage='When true, users on the system will be required to set up [multi-factor authentication]. Any logged in users will be redirected to the multi-factor authentication setup page until they successfully add MFA to their account.<br/><br/>It is recommended you turn on enforcement during non-peak hours, when people are less likely to be using the system. New users will be required to set up multi-factor authentication when they first sign up. After set up, users will not be able to remove multi-factor authentication unless enforcement is disabled.<br/><br/>Please note that multi-factor authentication is only available for accounts with LDAP and email login methods. Mattermost will not enforce multi-factor authentication for other login methods. If there are users on your system using other login methods, it is recommended you set up and enforce multi-factor authentication directly with the SSO or SAML provider.'
+ />
+ }
+ disabled={!this.state.enableMultifactorAuthentication}
+ value={this.state.enforceMultifactorAuthentication}
+ onChange={this.handleChange}
+ />
+ </SettingsGroup>
+ );
+ }
+}
diff --git a/webapp/components/admin_console/password_settings.jsx b/webapp/components/admin_console/password_settings.jsx
index 6fa1dc9c4..3707977b8 100644
--- a/webapp/components/admin_console/password_settings.jsx
+++ b/webapp/components/admin_console/password_settings.jsx
@@ -6,7 +6,6 @@ import AdminSettings from './admin_settings.jsx';
import {FormattedMessage} from 'react-intl';
import SettingsGroup from './settings_group.jsx';
import TextSetting from './text_setting.jsx';
-import BooleanSetting from './boolean_setting.jsx';
import Setting from './setting.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
@@ -32,7 +31,6 @@ export default class PasswordSettings extends AdminSettings {
passwordUppercase: props.config.PasswordSettings.Uppercase,
passwordSymbol: props.config.PasswordSettings.Symbol,
maximumLoginAttempts: props.config.ServiceSettings.MaximumLoginAttempts,
- enableMultifactorAuthentication: props.config.ServiceSettings.EnableMultifactorAuthentication,
passwordResetSalt: props.config.EmailSettings.PasswordResetSalt
});
@@ -75,9 +73,6 @@ export default class PasswordSettings extends AdminSettings {
config.ServiceSettings.MaximumLoginAttempts = this.parseIntNonZero(this.state.maximumLoginAttempts);
config.EmailSettings.PasswordResetSalt = this.state.passwordResetSalt;
- if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.MFA === 'true') {
- config.ServiceSettings.EnableMultifactorAuthentication = this.state.enableMultifactorAuthentication;
- }
return config;
}
@@ -90,7 +85,6 @@ export default class PasswordSettings extends AdminSettings {
passwordUppercase: config.PasswordSettings.Uppercase,
passwordSymbol: config.PasswordSettings.Symbol,
maximumLoginAttempts: config.ServiceSettings.MaximumLoginAttempts,
- enableMultifactorAuthentication: config.ServiceSettings.EnableMultifactorAuthentication,
passwordResetSalt: config.EmailSettings.PasswordResetSalt
};
}
@@ -154,29 +148,6 @@ export default class PasswordSettings extends AdminSettings {
}
renderSettings() {
- let mfaSetting = null;
- if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.MFA === 'true') {
- mfaSetting = (
- <BooleanSetting
- id='enableMultifactorAuthentication'
- label={
- <FormattedMessage
- id='admin.service.mfaTitle'
- defaultMessage='Enable Multi-factor Authentication:'
- />
- }
- helpText={
- <FormattedMessage
- id='admin.service.mfaDesc'
- defaultMessage='When true, users will be given the option to add multi-factor authentication to their account. They will need a smartphone and an authenticator app such as Google Authenticator.'
- />
- }
- value={this.state.enableMultifactorAuthentication}
- onChange={this.handleChange}
- />
- );
- }
-
let passwordSettings = null;
if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.PasswordRequirements === 'true') {
passwordSettings = (
@@ -332,8 +303,7 @@ export default class PasswordSettings extends AdminSettings {
value={this.state.maximumLoginAttempts}
onChange={this.handleChange}
/>
- {mfaSetting}
</SettingsGroup>
);
}
-} \ No newline at end of file
+}
diff --git a/webapp/components/claim/components/email_to_ldap.jsx b/webapp/components/claim/components/email_to_ldap.jsx
index a0b0b10e9..890512803 100644
--- a/webapp/components/claim/components/email_to_ldap.jsx
+++ b/webapp/components/claim/components/email_to_ldap.jsx
@@ -1,11 +1,14 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import LoginMfa from 'components/login/components/login_mfa.jsx';
+
import * as Utils from 'utils/utils.jsx';
import Client from 'client/web_client.jsx';
+import {checkMfa} from 'actions/user_actions.jsx';
+
import React from 'react';
-import ReactDOM from 'react-dom';
import {FormattedMessage} from 'react-intl';
export default class EmailToLDAP extends React.Component {
@@ -13,16 +16,20 @@ export default class EmailToLDAP extends React.Component {
super(props);
this.submit = this.submit.bind(this);
+ this.preSubmit = this.preSubmit.bind(this);
this.state = {
passwordError: '',
ldapError: '',
ldapPasswordError: '',
- serverError: ''
+ serverError: '',
+ showMfa: false
};
}
- submit(e) {
+
+ preSubmit(e) {
e.preventDefault();
+
var state = {
passwordError: '',
ldapError: '',
@@ -30,44 +37,65 @@ export default class EmailToLDAP extends React.Component {
serverError: ''
};
- const password = ReactDOM.findDOMNode(this.refs.emailpassword).value;
+ const password = this.refs.emailpassword.value;
if (!password) {
state.passwordError = Utils.localizeMessage('claim.email_to_ldap.pwdError', 'Please enter your password.');
this.setState(state);
return;
}
- const ldapId = ReactDOM.findDOMNode(this.refs.ldapid).value.trim();
+ const ldapId = this.refs.ldapid.value.trim();
if (!ldapId) {
state.ldapError = Utils.localizeMessage('claim.email_to_ldap.ldapIdError', 'Please enter your AD/LDAP ID.');
this.setState(state);
return;
}
- const ldapPassword = ReactDOM.findDOMNode(this.refs.ldappassword).value;
+ const ldapPassword = this.refs.ldappassword.value;
if (!ldapPassword) {
state.ldapPasswordError = Utils.localizeMessage('claim.email_to_ldap.ldapPasswordError', 'Please enter your AD/LDAP password.');
this.setState(state);
return;
}
+ state.password = password;
+ state.ldapId = ldapId;
+ state.ldapPassword = ldapPassword;
this.setState(state);
- Client.emailToLdap(
+ checkMfa(
this.props.email,
+ (requiresMfa) => {
+ if (requiresMfa) {
+ this.setState({showMfa: true});
+ } else {
+ this.submit(this.props.email, password, '', ldapId, ldapPassword);
+ }
+ },
+ (err) => {
+ this.setState({error: err.message});
+ }
+ );
+ }
+
+ submit(loginId, password, token, ldapId, ldapPassword) {
+ Client.emailToLdap(
+ loginId,
password,
- ldapId,
- ldapPassword,
+ token,
+ ldapId || this.state.ldapId,
+ ldapPassword || this.state.ldapPassword,
(data) => {
if (data.follow_link) {
window.location.href = data.follow_link;
}
},
(err) => {
- this.setState({serverError: err.message});
+ this.setState({serverError: err.message, showMfa: false});
}
);
}
+
render() {
let serverError = null;
let formClass = 'form-group';
@@ -111,16 +139,19 @@ export default class EmailToLDAP extends React.Component {
passwordPlaceholder = Utils.localizeMessage('claim.email_to_ldap.ldapPwd', 'AD/LDAP Password');
}
- return (
- <div>
- <h3>
- <FormattedMessage
- id='claim.email_to_ldap.title'
- defaultMessage='Switch Email/Password Account to AD/LDAP'
- />
- </h3>
+ let content;
+ if (this.state.showMfa) {
+ content = (
+ <LoginMfa
+ loginId={this.props.email}
+ password={this.state.password}
+ submit={this.submit}
+ />
+ );
+ } else {
+ content = (
<form
- onSubmit={this.submit}
+ onSubmit={this.preSubmit}
className={formClass}
>
<p>
@@ -202,6 +233,18 @@ export default class EmailToLDAP extends React.Component {
</button>
{serverError}
</form>
+ );
+ }
+
+ return (
+ <div>
+ <h3>
+ <FormattedMessage
+ id='claim.email_to_ldap.title'
+ defaultMessage='Switch Email/Password Account to AD/LDAP'
+ />
+ </h3>
+ {content}
</div>
);
}
diff --git a/webapp/components/claim/components/email_to_oauth.jsx b/webapp/components/claim/components/email_to_oauth.jsx
index d7c4956a6..3cede15a3 100644
--- a/webapp/components/claim/components/email_to_oauth.jsx
+++ b/webapp/components/claim/components/email_to_oauth.jsx
@@ -1,10 +1,14 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import LoginMfa from 'components/login/components/login_mfa.jsx';
+
import * as Utils from 'utils/utils.jsx';
import Client from 'client/web_client.jsx';
import Constants from 'utils/constants.jsx';
+import {checkMfa} from 'actions/user_actions.jsx';
+
import React from 'react';
import ReactDOM from 'react-dom';
import {FormattedMessage} from 'react-intl';
@@ -14,10 +18,12 @@ export default class EmailToOAuth extends React.Component {
super(props);
this.submit = this.submit.bind(this);
+ this.preSubmit = this.preSubmit.bind(this);
- this.state = {};
+ this.state = {showMfa: false, password: ''};
}
- submit(e) {
+
+ preSubmit(e) {
e.preventDefault();
var state = {};
@@ -28,12 +34,31 @@ export default class EmailToOAuth extends React.Component {
return;
}
+ this.setState({password});
+
state.error = null;
this.setState(state);
- Client.emailToOAuth(
+ checkMfa(
this.props.email,
+ (requiresMfa) => {
+ if (requiresMfa) {
+ this.setState({showMfa: true});
+ } else {
+ this.submit(this.props.email, password, '');
+ }
+ },
+ (err) => {
+ this.setState({error: err.message});
+ }
+ );
+ }
+
+ submit(loginId, password, token) {
+ Client.emailToOAuth(
+ loginId,
password,
+ token,
this.props.newType,
(data) => {
if (data.follow_link) {
@@ -41,10 +66,11 @@ export default class EmailToOAuth extends React.Component {
}
},
(err) => {
- this.setState({error: err.message});
+ this.setState({error: err.message, showMfa: false});
}
);
}
+
render() {
var error = null;
if (this.state.error) {
@@ -59,18 +85,18 @@ export default class EmailToOAuth extends React.Component {
const type = (this.props.newType === Constants.SAML_SERVICE ? Constants.SAML_SERVICE.toUpperCase() : Utils.toTitleCase(this.props.newType));
const uiType = `${type} SSO`;
- return (
- <div>
- <h3>
- <FormattedMessage
- id='claim.email_to_oauth.title'
- defaultMessage='Switch Email/Password Account to {uiType}'
- values={{
- uiType
- }}
- />
- </h3>
- <form onSubmit={this.submit}>
+ let content;
+ if (this.state.showMfa) {
+ content = (
+ <LoginMfa
+ loginId={this.props.email}
+ password={this.state.password}
+ submit={this.submit}
+ />
+ );
+ } else {
+ content = (
+ <form onSubmit={this.preSubmit}>
<p>
<FormattedMessage
id='claim.email_to_oauth.ssoType'
@@ -122,6 +148,21 @@ export default class EmailToOAuth extends React.Component {
/>
</button>
</form>
+ );
+ }
+
+ return (
+ <div>
+ <h3>
+ <FormattedMessage
+ id='claim.email_to_oauth.title'
+ defaultMessage='Switch Email/Password Account to {uiType}'
+ values={{
+ uiType
+ }}
+ />
+ </h3>
+ {content}
</div>
);
}
diff --git a/webapp/components/claim/components/ldap_to_email.jsx b/webapp/components/claim/components/ldap_to_email.jsx
index b7ff93b59..39056cd0d 100644
--- a/webapp/components/claim/components/ldap_to_email.jsx
+++ b/webapp/components/claim/components/ldap_to_email.jsx
@@ -1,9 +1,11 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import LoginMfa from 'components/login/components/login_mfa.jsx';
+
import * as Utils from 'utils/utils.jsx';
-import {switchFromLdapToEmail} from 'actions/user_actions.jsx';
+import {checkMfa, switchFromLdapToEmail} from 'actions/user_actions.jsx';
import React from 'react';
import {FormattedMessage} from 'react-intl';
@@ -13,6 +15,7 @@ export default class LDAPToEmail extends React.Component {
super(props);
this.submit = this.submit.bind(this);
+ this.preSubmit = this.preSubmit.bind(this);
this.state = {
passwordError: '',
@@ -22,8 +25,9 @@ export default class LDAPToEmail extends React.Component {
};
}
- submit(e) {
+ preSubmit(e) {
e.preventDefault();
+
var state = {
passwordError: '',
confirmError: '',
@@ -60,14 +64,33 @@ export default class LDAPToEmail extends React.Component {
return;
}
+ state.password = password;
+ state.ldapPassword = ldapPassword;
this.setState(state);
+ checkMfa(
+ this.props.email,
+ (requiresMfa) => {
+ if (requiresMfa) {
+ this.setState({showMfa: true});
+ } else {
+ this.submit(this.props.email, password, '', ldapPassword);
+ }
+ },
+ (err) => {
+ this.setState({error: err.message});
+ }
+ );
+ }
+
+ submit(loginId, password, token, ldapPassword) {
switchFromLdapToEmail(
this.props.email,
password,
- ldapPassword,
+ token,
+ ldapPassword || this.state.ldapPassword,
null,
- (err) => this.setState({serverError: err.message})
+ (err) => this.setState({serverError: err.message, showMfa: false})
);
}
@@ -107,16 +130,19 @@ export default class LDAPToEmail extends React.Component {
passwordPlaceholder = Utils.localizeMessage('claim.ldap_to_email.ldapPwd', 'AD/LDAP Password');
}
- return (
- <div>
- <h3>
- <FormattedMessage
- id='claim.ldap_to_email.title'
- defaultMessage='Switch AD/LDAP Account to Email/Password'
- />
- </h3>
+ let content;
+ if (this.state.showMfa) {
+ content = (
+ <LoginMfa
+ loginId={this.props.email}
+ password={this.state.password}
+ submit={this.submit}
+ />
+ );
+ } else {
+ content = (
<form
- onSubmit={this.submit}
+ onSubmit={this.preSubmit}
className={formClass}
>
<p>
@@ -194,6 +220,18 @@ export default class LDAPToEmail extends React.Component {
</button>
{serverError}
</form>
+ );
+ }
+
+ return (
+ <div>
+ <h3>
+ <FormattedMessage
+ id='claim.ldap_to_email.title'
+ defaultMessage='Switch AD/LDAP Account to Email/Password'
+ />
+ </h3>
+ {content}
</div>
);
}
diff --git a/webapp/components/login/components/login_mfa.jsx b/webapp/components/login/components/login_mfa.jsx
index ce77c9fa9..1a3393fa0 100644
--- a/webapp/components/login/components/login_mfa.jsx
+++ b/webapp/components/login/components/login_mfa.jsx
@@ -17,6 +17,7 @@ export default class LoginMfa extends React.Component {
serverError: ''
};
}
+
handleSubmit(e) {
e.preventDefault();
const state = {};
@@ -33,6 +34,7 @@ export default class LoginMfa extends React.Component {
this.props.submit(this.props.loginId, this.props.password, token);
}
+
render() {
let serverError;
let errorClass = '';
diff --git a/webapp/components/mfa/components/confirm.jsx b/webapp/components/mfa/components/confirm.jsx
new file mode 100644
index 000000000..026d12c6e
--- /dev/null
+++ b/webapp/components/mfa/components/confirm.jsx
@@ -0,0 +1,75 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import Constants from 'utils/constants.jsx';
+const KeyCodes = Constants.KeyCodes;
+
+import React from 'react';
+import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
+import {browserHistory} from 'react-router/es6';
+
+export default class Confirm extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.onKeyPress = this.onKeyPress.bind(this);
+ }
+
+ componentDidMount() {
+ document.body.addEventListener('keydown', this.onKeyPress);
+ }
+
+ componentWillUnmount() {
+ document.body.removeEventListener('keydown', this.onKeyPress);
+ }
+
+ submit(e) {
+ e.preventDefault();
+ browserHistory.push('/');
+ }
+
+ onKeyPress(e) {
+ if (e.which === KeyCodes.ENTER) {
+ this.submit(e);
+ }
+ }
+
+ render() {
+ return (
+ <div>
+ <form
+ onSubmit={this.submit}
+ onKeyPress={this.onKeyPress}
+ className='form-group'
+ >
+ <p>
+ <FormattedHTMLMessage
+ id='mfa.confirm.complete'
+ defaultMessage='<strong>Set up complete!</strong>'
+ />
+ </p>
+ <p>
+ <FormattedMessage
+ id='mfa.confirm.secure'
+ defaultMessage='Your account is now secure. Next time you sign in, you will be asked to enter a code from the Google Authenticator app on your phone.'
+ />
+ </p>
+ <button
+ type='submit'
+ className='btn btn-primary'
+ >
+ <FormattedMessage
+ id='mfa.confirm.okay'
+ defaultMessage='Okay'
+ />
+ </button>
+ </form>
+ </div>
+ );
+ }
+}
+
+Confirm.defaultProps = {
+};
+Confirm.propTypes = {
+};
diff --git a/webapp/components/mfa/components/setup.jsx b/webapp/components/mfa/components/setup.jsx
new file mode 100644
index 000000000..f7a287c15
--- /dev/null
+++ b/webapp/components/mfa/components/setup.jsx
@@ -0,0 +1,156 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {generateMfaSecret, activateMfa} from 'actions/user_actions.jsx';
+
+import UserStore from 'stores/user_store.jsx';
+
+import * as Utils from 'utils/utils.jsx';
+
+import React from 'react';
+import {FormattedMessage, FormattedHTMLMessage} from 'react-intl';
+import {browserHistory} from 'react-router/es6';
+
+export default class Setup extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.submit = this.submit.bind(this);
+
+ this.state = {secret: '', qrCode: ''};
+ }
+
+ componentDidMount() {
+ const user = UserStore.getCurrentUser();
+ if (!user || user.mfa_active) {
+ browserHistory.push('/');
+ return;
+ }
+
+ generateMfaSecret(
+ (data) => this.setState({secret: data.secret, qrCode: data.qr_code}),
+ (err) => this.setState({serverError: err.message})
+ );
+ }
+
+ submit(e) {
+ e.preventDefault();
+ const code = this.refs.code.value.replace(/\s/g, '');
+ if (!code || code.length === 0) {
+ this.setState({error: Utils.localizeMessage('mfa.setup.codeError', 'Please enter the code from Google Authenticator.')});
+ return;
+ }
+
+ this.setState({error: null});
+
+ activateMfa(
+ code,
+ () => {
+ browserHistory.push('/mfa/confirm');
+ },
+ (err) => {
+ if (err.id === 'ent.mfa.activate.authenticate.app_error') {
+ this.setState({error: Utils.localizeMessage('mfa.setup.badCode', 'Invalid code. If this issue persists, contact your System Administrator.')});
+ return;
+ }
+ this.setState({error: err.message});
+ }
+ );
+ }
+
+ render() {
+ let formClass = 'form-group';
+ let errorContent;
+ if (this.state.error) {
+ errorContent = <div className='form-group has-error'><label className='control-label'>{this.state.error}</label></div>;
+ formClass += ' has-error';
+ }
+
+ let mfaRequired;
+ if (global.window.mm_config.EnforceMultifactorAuthentication) {
+ mfaRequired = (
+ <p>
+ <FormattedHTMLMessage
+ id='mfa.setup.required'
+ defaultMessage='<strong>Multi-factor authentication is required on {siteName}.</strong>'
+ values={{
+ siteName: global.window.mm_config.SiteName
+ }}
+ />
+ </p>
+ );
+ }
+
+ return (
+ <div>
+ <form
+ onSubmit={this.submit}
+ className={formClass}
+ >
+ {mfaRequired}
+ <p>
+ <FormattedHTMLMessage
+ id='mfa.setup.step1'
+ defaultMessage="<strong>Step 1: </strong>On your phone, download Google Authenticator from <a target='_blank' href='https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8'>iTunes</a> or <a target='_blank' href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en'>Google Play</a>"
+ />
+ </p>
+ <p>
+ <FormattedHTMLMessage
+ id='mfa.setup.step2'
+ defaultMessage='<strong>Step 2: </strong>Use Google Authenticator to scan this QR code, or manually type in the secret key'
+ />
+ </p>
+ <div className='form-group'>
+ <div className='col-sm-12'>
+ <img
+ style={{maxHeight: 170}}
+ src={'data:image/png;base64,' + this.state.qrCode}
+ />
+ </div>
+ </div>
+ <br/>
+ <div className='form-group'>
+ <p className='col-sm-12'>
+ <FormattedMessage
+ id='mfa.setup.secret'
+ defaultMessage='Secret: {secret}'
+ values={{
+ secret: this.state.secret
+ }}
+ />
+ </p>
+ </div>
+ <p>
+ <FormattedHTMLMessage
+ id='mfa.setup.step3'
+ defaultMessage='<strong>Step 3: </strong>Enter the code generated by Google Authenticator'
+ />
+ </p>
+ <p>
+ <input
+ ref='code'
+ className='form-control'
+ placeholder={Utils.localizeMessage('mfa.setup.code', 'MFA Code')}
+ autoFocus={true}
+ />
+ </p>
+ {errorContent}
+ <button
+ type='submit'
+ className='btn btn-primary'
+ >
+ <FormattedMessage
+ id='mfa.setup.save'
+ defaultMessage='Save'
+ />
+ </button>
+ </form>
+ </div>
+ );
+ }
+}
+
+Setup.defaultProps = {
+};
+Setup.propTypes = {
+};
diff --git a/webapp/components/mfa/mfa_controller.jsx b/webapp/components/mfa/mfa_controller.jsx
new file mode 100644
index 000000000..21b9737f8
--- /dev/null
+++ b/webapp/components/mfa/mfa_controller.jsx
@@ -0,0 +1,66 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+import {browserHistory, Link} from 'react-router/es6';
+
+import logoImage from 'images/logo.png';
+
+export default class MFAController extends React.Component {
+ componentDidMount() {
+ if (window.mm_license.MFA !== 'true' || window.mm_config.EnableMultifactorAuthentication !== 'true') {
+ browserHistory.push('/');
+ }
+ }
+
+ render() {
+ let backButton;
+ if (window.mm_config.EnforceMultifactorAuthentication !== 'true') {
+ backButton = (
+ <div className='signup-header'>
+ <Link to='/'>
+ <span className='fa fa-chevron-left'/>
+ <FormattedMessage
+ id='web.header.back'
+ />
+ </Link>
+ </div>
+ );
+ }
+
+ return (
+ <div className='inner-wrap sticky'>
+ <div className='content'>
+ <div>
+ {backButton}
+ <div className='col-sm-12'>
+ <div className='signup-team__container'>
+ <h3>
+ <FormattedMessage
+ id='mfa.setupTitle'
+ defaultMessage='Multi-factor Authentication Setup'
+ />
+ </h3>
+ <img
+ className='signup-team-logo'
+ src={logoImage}
+ />
+ <div id='mfa'>
+ {React.cloneElement(this.props.children, {})}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+MFAController.defaultProps = {
+};
+MFAController.propTypes = {
+ location: React.PropTypes.object.isRequired,
+ children: React.PropTypes.node
+};
diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx
index 5f231e499..3484b8183 100644
--- a/webapp/components/user_settings/user_settings_security.jsx
+++ b/webapp/components/user_settings/user_settings_security.jsx
@@ -9,8 +9,6 @@ import ToggleModalButton from '../toggle_modal_button.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
-import {generateMfaSecret} from 'actions/user_actions.jsx';
-
import Client from 'client/web_client.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx';
@@ -18,8 +16,8 @@ import Constants from 'utils/constants.jsx';
import $ from 'jquery';
import React from 'react';
-import {FormattedMessage, FormattedHTMLMessage, FormattedTime, FormattedDate} from 'react-intl';
-import {Link} from 'react-router/es6';
+import {FormattedMessage, FormattedTime, FormattedDate} from 'react-intl';
+import {browserHistory, Link} from 'react-router/es6';
import icon50 from 'images/icon50x50.png';
@@ -28,17 +26,15 @@ export default class SecurityTab extends React.Component {
super(props);
this.submitPassword = this.submitPassword.bind(this);
- this.activateMfa = this.activateMfa.bind(this);
+ this.setupMfa = this.setupMfa.bind(this);
this.deactivateMfa = this.deactivateMfa.bind(this);
this.updateCurrentPassword = this.updateCurrentPassword.bind(this);
this.updateNewPassword = this.updateNewPassword.bind(this);
this.updateConfirmPassword = this.updateConfirmPassword.bind(this);
- this.updateMfaToken = this.updateMfaToken.bind(this);
this.getDefaultState = this.getDefaultState.bind(this);
this.createPasswordSection = this.createPasswordSection.bind(this);
this.createSignInSection = this.createSignInSection.bind(this);
this.createOAuthAppsSection = this.createOAuthAppsSection.bind(this);
- this.showQrCode = this.showQrCode.bind(this);
this.deauthorizeApp = this.deauthorizeApp.bind(this);
this.state = this.getDefaultState();
@@ -51,9 +47,7 @@ export default class SecurityTab extends React.Component {
confirmPassword: '',
passwordError: '',
serverError: '',
- authService: this.props.user.auth_service,
- mfaShowQr: false,
- mfaToken: ''
+ authService: this.props.user.auth_service
};
}
@@ -119,26 +113,9 @@ export default class SecurityTab extends React.Component {
);
}
- activateMfa() {
- Client.updateMfa(
- this.state.mfaToken,
- true,
- () => {
- this.props.updateSection('');
- AsyncClient.getMe();
- this.setState(this.getDefaultState());
- },
- (err) => {
- const state = this.getDefaultState();
- if (err.message) {
- state.serverError = err.message;
- } else {
- state.serverError = err;
- }
- state.mfaError = '';
- this.setState(state);
- }
- );
+ setupMfa(e) {
+ e.preventDefault();
+ browserHistory.push('/mfa/setup');
}
deactivateMfa() {
@@ -146,6 +123,13 @@ export default class SecurityTab extends React.Component {
'',
false,
() => {
+ if (global.window.mm_license.MFA === 'true' &&
+ global.window.mm_config.EnableMultifactorAuthentication === 'true' &&
+ global.window.mm_config.EnforceMultifactorAuthentication === 'true') {
+ window.location.href = '/mfa/setup';
+ return;
+ }
+
this.props.updateSection('');
AsyncClient.getMe();
this.setState(this.getDefaultState());
@@ -157,7 +141,6 @@ export default class SecurityTab extends React.Component {
} else {
state.serverError = err;
}
- state.mfaError = '';
this.setState(state);
}
);
@@ -175,18 +158,6 @@ export default class SecurityTab extends React.Component {
this.setState({confirmPassword: e.target.value});
}
- updateMfaToken(e) {
- this.setState({mfaToken: e.target.value});
- }
-
- showQrCode(e) {
- e.preventDefault();
- generateMfaSecret(
- (data) => this.setState({mfaShowQr: true, secret: data.secret, qrCode: data.qr_code}),
- (err) => this.setState({serverError: err.message})
- );
- }
-
deauthorizeApp(e) {
e.preventDefault();
const appId = e.currentTarget.getAttribute('data-app');
@@ -212,6 +183,39 @@ export default class SecurityTab extends React.Component {
let content;
let extraInfo;
if (this.props.user.mfa_active) {
+ let mfaRemoveHelp;
+ let mfaButtonText;
+
+ if (global.window.mm_config.EnforceMultifactorAuthentication === 'true') {
+ mfaRemoveHelp = (
+ <FormattedMessage
+ id='user.settings.mfa.requiredHelp'
+ defaultMessage='Multi-factor authentication is required on this server. Resetting is only recommended when you need to switch code generation to a new mobile device. You will be required to set it up again immediately.'
+ />
+ );
+
+ mfaButtonText = (
+ <FormattedMessage
+ id='user.settings.mfa.reset'
+ defaultMessage='Reset MFA on your account'
+ />
+ );
+ } else {
+ mfaRemoveHelp = (
+ <FormattedMessage
+ id='user.settings.mfa.removeHelp'
+ defaultMessage='Removing multi-factor authentication means you will no longer require a phone-based passcode to sign-in to your account.'
+ />
+ );
+
+ mfaButtonText = (
+ <FormattedMessage
+ id='user.settings.mfa.remove'
+ defaultMessage='Remove MFA from your account'
+ />
+ );
+ }
+
content = (
<div key='mfaQrCode'>
<a
@@ -219,10 +223,7 @@ export default class SecurityTab extends React.Component {
href='#'
onClick={this.deactivateMfa}
>
- <FormattedMessage
- id='user.settings.mfa.remove'
- defaultMessage='Remove MFA from your account'
- />
+ {mfaButtonText}
</a>
<br/>
</div>
@@ -230,78 +231,16 @@ export default class SecurityTab extends React.Component {
extraInfo = (
<span>
- <FormattedMessage
- id='user.settings.mfa.removeHelp'
- defaultMessage='Removing multi-factor authentication will make your account more vulnerable to attacks.'
- />
+ {mfaRemoveHelp}
</span>
);
- } else if (this.state.mfaShowQr) {
- content = (
- <div key='mfaButton'>
- <div className='form-group'>
- <label className='col-sm-3 control-label'>
- <FormattedMessage
- id='user.settings.mfa.qrCode'
- defaultMessage='Bar Code'
- />
- </label>
- <div className='col-sm-5'>
- <img
- className='qr-code-img'
- src={'data:image/png;base64,' + this.state.qrCode}
- />
- </div>
- </div>
- <div className='form-group'>
- <label className='col-sm-3 control-label'>
- <FormattedMessage
- id='user.settings.mfa.secret'
- defaultMessage='Secret'
- />
- </label>
- <div className='col-sm-9 padding-top'>
- {this.state.secret}
- </div>
- </div>
- <hr/>
- <div className='form-group'>
- <label className='col-sm-5 control-label'>
- <FormattedMessage
- id='user.settings.mfa.enterToken'
- defaultMessage='Token (numbers only)'
- />
- </label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='number'
- autoFocus={true}
- onChange={this.updateMfaToken}
- value={this.state.mfaToken}
- />
- </div>
- </div>
- </div>
- );
-
- extraInfo = (
- <span>
- <FormattedMessage
- id='user.settings.mfa.addHelpQr'
- defaultMessage='Please scan the QR code with the Google Authenticator app on your smartphone and fill in the token with one provided by the app. If you are unable to scan the code, you can manually enter the secret provided.'
- />
- </span>
- );
-
- submit = this.activateMfa;
} else {
content = (
<div key='mfaQrCode'>
<a
className='btn btn-primary'
href='#'
- onClick={this.showQrCode}
+ onClick={this.setupMfa}
>
<FormattedMessage
id='user.settings.mfa.add'
@@ -314,9 +253,9 @@ export default class SecurityTab extends React.Component {
extraInfo = (
<span>
- <FormattedHTMLMessage
+ <FormattedMessage
id='user.settings.mfa.addHelp'
- defaultMessage="You can require a smartphone-based token, in addition to your password, to sign into Mattermost.<br/><br/>To enable, download Google Authenticator from <a target='_blank' href='https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8'>iTunes</a> or <a target='_blank' href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en'>Google Play</a> for your phone, then<br/><br/>1. Click the <strong>Add MFA to your account</strong> button above.<br/>2. Use Google Authenticator to scan the QR code that appears or type in the secret manually.<br/>3. Type in the Token generated by Google Authenticator and click <strong>Save</strong>.<br/><br/>When logging in, you will be asked to enter a token from Google Authenticator in addition to your regular credentials."
+ defaultMessage='Adding multi-factor authentication will make your account more secure by requiring a code from your mobile phone each time you sign in.'
/>
</span>
);
@@ -334,7 +273,7 @@ export default class SecurityTab extends React.Component {
updateSectionStatus = function resetSection(e) {
this.props.updateSection('');
- this.setState({mfaToken: '', mfaShowQr: false, mfaError: null, serverError: null});
+ this.setState({serverError: null});
e.preventDefault();
}.bind(this);
@@ -345,7 +284,6 @@ export default class SecurityTab extends React.Component {
extraInfo={extraInfo}
submit={submit}
server_error={this.state.serverError}
- client_error={this.state.mfaError}
updateSection={updateSectionStatus}
width='medium'
/>
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 5cc7f0bbf..d409aaec7 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -136,6 +136,12 @@
"add_outgoing_webhook.triggerWordsTriggerWhen.help": "Choose when to trigger the outgoing webhook; if the first word of a message matches a Trigger Word exactly, or if it starts with a Trigger Word.",
"add_outgoing_webhook.triggerWordsTriggerWhenFullWord": "First word matches a trigger word exactly",
"add_outgoing_webhook.triggerWordsTriggerWhenStartsWith": "First word starts with a trigger word",
+ "admin.mfa.title": "Multi-factor Authentication",
+ "admin.mfa.bannerDesc": "Multi-factor authentication is only available for accounts with LDAP and email login methods. If there are users on your system with other login methods, it is recommended you set up multi-factor authentication directly with the SSO or SAML provider.",
+ "admin.mfa.cluster": "High",
+ "admin.mfa.cluster": "High",
+ "admin.mfa.cluster": "High",
+ "admin.mfa.cluster": "High",
"admin.advance.cluster": "High Availability (Beta)",
"admin.advance.metrics": "Performance Monitoring (Beta)",
"admin.audits.reload": "Reload User Activity Logs",
@@ -668,6 +674,8 @@
"admin.service.listenExample": "E.g.: \":8065\"",
"admin.service.mfaDesc": "When true, users will be given the option to add multi-factor authentication to their account. They will need a smartphone and an authenticator app such as Google Authenticator.",
"admin.service.mfaTitle": "Enable Multi-factor Authentication:",
+ "admin.service.enforcMfaTitle": "Enforce Multi-factor Authentication:",
+ "admin.service.enforceMfaDesc": "When true, users on the system will be required to set up [multi-factor authentication]. Any logged in users will be redirected to the multi-factor authentication setup page until they successfully add MFA to their account.<br/><br/>It is recommended you turn on enforcement during non-peak hours, when people are less likely to be using the system. New users will be required to set up multi-factor authentication when they first sign up. After set up, users will not be able to remove multi-factor authentication unless enforcement is disabled.<br/><br/>Please note that multi-factor authentication is only available for accounts with LDAP and email login methods. Mattermost will not enforce multi-factor authentication for other login methods. If there are users on your system using other login methods, it is recommended you set up and enforce multi-factor authentication directly with the SSO or SAML provider.",
"admin.service.mobileSessionDays": "Session length mobile (days):",
"admin.service.mobileSessionDaysDesc": "The number of days from the last time a user entered their credentials to the expiry of the user's session. After changing this setting, the new session length will take effect after the next time the user enters their credentials.",
"admin.service.outWebhooksDesc": "When true, outgoing webhooks will be allowed. See <a href='http://docs.mattermost.com/developer/webhooks-outgoing.html' target='_blank'>documentation</a> to learn more.",
@@ -1979,14 +1987,29 @@
"user.settings.languages.change": "Change interface language",
"user.settings.languages.promote": "Select which language Mattermost displays in the user interface.<br /><br />Would like to help with translations? Join the <a href='http://translate.mattermost.com/' target='_blank'>Mattermost Translation Server</a> to contribute.",
"user.settings.mfa.add": "Add MFA to your account",
- "user.settings.mfa.addHelp": "You can require a smartphone-based token, in addition to your password, to sign into Mattermost.<br/><br/>To enable, download Google Authenticator from <a target='_blank' href='https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8'>iTunes</a> or <a target='_blank' href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en'>Google Play</a> for your phone, then<br/><br/>1. Click the <strong>Add MFA to your account</strong> button above.<br/>2. Use Google Authenticator to scan the QR code that appears or type in the secret manually.<br/>3. Type in the Token generated by Google Authenticator and click <strong>Save</strong>.<br/><br/>When logging in, you will be asked to enter a token from Google Authenticator in addition to your regular credentials.",
+ "user.settings.mfa.addHelp": "Adding multi-factor authentication will make your account more secure by requiring a code from your mobile phone each time you sign in.",
"user.settings.mfa.addHelpQr": "Please scan the QR code with the Google Authenticator app on your smartphone and fill in the token with one provided by the app. If you are unable to scan the code, you can manually enter the secret provided.",
"user.settings.mfa.enterToken": "Token (numbers only)",
"user.settings.mfa.qrCode": "Bar Code",
"user.settings.mfa.remove": "Remove MFA from your account",
+ "user.settings.mfa.reset": "Reset MFA on your account",
"user.settings.mfa.removeHelp": "Removing multi-factor authentication means you will no longer require a phone-based passcode to sign-in to your account.",
+ "user.settings.mfa.requiredHelp": "Multi-factor authentication is required on this server. Resetting is only recommended when you need to switch code generation to a new mobile device. You will be required to set it up again immediately.",
"user.settings.mfa.secret": "Secret",
"user.settings.mfa.title": "Multi-factor Authentication",
+ "mfa.setupTitle": "Multi-factor Authentication Setup",
+ "mfa.setup.codeError": "Please enter the code from Google Authenticator.",
+ "mfa.setup.badCode": "Invalid code. If this issue persists, contact your System Administrator.",
+ "mfa.setup.required": "<strong>Multi-factor authentication is required on {siteName}.</strong>",
+ "mfa.setup.step1": "<strong>Step 1: </strong>On your phone, download Google Authenticator from <a target='_blank' href='https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8'>iTunes</a> or <a target='_blank' href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en'>Google Play</a>",
+ "mfa.setup.step2": "<strong>Step 2: </strong>Use Google Authenticator to scan this QR code, or manually type in the secret key",
+ "mfa.setup.secret": "Secret: {secret}",
+ "mfa.setup.step3": "<strong>Step 3: </strong>Enter the code generated by Google Authenticator",
+ "mfa.setup.code": "MFA Code",
+ "mfa.setup.save": "Save",
+ "mfa.confirm.complete": "<strong>Set up complete!</strong>",
+ "mfa.confirm.secure": "Your account is now secure. Next time you sign in, you will be asked to enter a code from the Google Authenticator app on your phone.",
+ "mfa.confirm.okay": "Okay",
"user.settings.modal.advanced": "Advanced",
"user.settings.modal.confirmBtns": "Yes, Discard",
"user.settings.modal.confirmMsg": "You have unsaved changes, are you sure you want to discard them?",
diff --git a/webapp/routes/route_admin_console.jsx b/webapp/routes/route_admin_console.jsx
index a67cb3e83..5b0f5d28e 100644
--- a/webapp/routes/route_admin_console.jsx
+++ b/webapp/routes/route_admin_console.jsx
@@ -21,6 +21,7 @@ import ClusterSettings from 'components/admin_console/cluster_settings.jsx';
import MetricsSettings from 'components/admin_console/metrics_settings.jsx';
import SignupSettings from 'components/admin_console/signup_settings.jsx';
import PasswordSettings from 'components/admin_console/password_settings.jsx';
+import MfaSettings from 'components/admin_console/mfa_settings.jsx';
import PublicLinkSettings from 'components/admin_console/public_link_settings.jsx';
import SessionSettings from 'components/admin_console/session_settings.jsx';
import ConnectionSettings from 'components/admin_console/connection_settings.jsx';
@@ -104,6 +105,10 @@ export default (
path='saml'
component={SamlSettings}
/>
+ <Route
+ path='mfa'
+ component={MfaSettings}
+ />
</Route>
<Route path='security'>
<IndexRedirect to='sign_up'/>
diff --git a/webapp/routes/route_mfa.jsx b/webapp/routes/route_mfa.jsx
new file mode 100644
index 000000000..517d3802e
--- /dev/null
+++ b/webapp/routes/route_mfa.jsx
@@ -0,0 +1,24 @@
+import * as RouteUtils from 'routes/route_utils.jsx';
+
+export default {
+ path: 'mfa',
+ getComponents: (location, callback) => {
+ System.import('components/mfa/mfa_controller.jsx').then(RouteUtils.importComponentSuccess(callback));
+ },
+ getChildRoutes: RouteUtils.createGetChildComponentsFunction(
+ [
+ {
+ path: 'setup',
+ getComponents: (location, callback) => {
+ System.import('components/mfa/components/setup.jsx').then(RouteUtils.importComponentSuccess(callback));
+ }
+ },
+ {
+ path: 'confirm',
+ getComponents: (location, callback) => {
+ System.import('components/mfa/components/confirm.jsx').then(RouteUtils.importComponentSuccess(callback));
+ }
+ }
+ ]
+ )
+};
diff --git a/webapp/routes/route_root.jsx b/webapp/routes/route_root.jsx
index 9d64c6012..f72e35302 100644
--- a/webapp/routes/route_root.jsx
+++ b/webapp/routes/route_root.jsx
@@ -6,14 +6,18 @@ import * as RouteUtils from 'routes/route_utils.jsx';
import Root from 'components/root.jsx';
import claimAccountRoute from 'routes/route_claim.jsx';
+import mfaRoute from 'routes/route_mfa.jsx';
import createTeamRoute from 'routes/route_create_team.jsx';
import teamRoute from 'routes/route_team.jsx';
import helpRoute from 'routes/route_help.jsx';
import BrowserStore from 'stores/browser_store.jsx';
import ErrorStore from 'stores/error_store.jsx';
+import UserStore from 'stores/user_store.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
+import {browserHistory} from 'react-router/es6';
+
function preLogin(nextState, replace, callback) {
// redirect to the mobile landing page if the user hasn't seen it before
if (window.mm_config.IosAppDownloadLink && UserAgent.isIosWeb() && !BrowserStore.hasSeenLandingPage()) {
@@ -27,7 +31,30 @@ function preLogin(nextState, replace, callback) {
callback();
}
+const mfaPaths = [
+ '/mfa/setup',
+ '/mfa/confirm'
+];
+
+const mfaAuthServices = [
+ '',
+ 'email',
+ 'ldap'
+];
+
function preLoggedIn(nextState, replace, callback) {
+ if (window.mm_license.MFA === 'true' &&
+ window.mm_config.EnableMultifactorAuthentication === 'true' &&
+ window.mm_config.EnforceMultifactorAuthentication === 'true' &&
+ mfaPaths.indexOf(nextState.location.pathname) === -1) {
+ const user = UserStore.getCurrentUser();
+ if (user && !user.mfa_active &&
+ mfaAuthServices.indexOf(user.auth_service) !== -1) {
+ browserHistory.push('/mfa/setup');
+ return;
+ }
+ }
+
ErrorStore.clearLastError();
callback();
}
@@ -154,7 +181,8 @@ export default {
]
)
},
- teamRoute
+ teamRoute,
+ mfaRoute
]
)
},
diff --git a/webapp/sass/base/_structure.scss b/webapp/sass/base/_structure.scss
index 217488334..60673f9a2 100644
--- a/webapp/sass/base/_structure.scss
+++ b/webapp/sass/base/_structure.scss
@@ -32,6 +32,10 @@ body {
.inner-wrap {
height: 100%;
+ &.sticky {
+ overflow: auto;
+ }
+
> .row {
&.main {
height: 100%;
diff --git a/webapp/tests/client_user.test.jsx b/webapp/tests/client_user.test.jsx
index 5e5eb6821..3af29661a 100644
--- a/webapp/tests/client_user.test.jsx
+++ b/webapp/tests/client_user.test.jsx
@@ -306,6 +306,7 @@ describe('Client.User', function() {
TestHelper.basicClient().emailToOAuth(
user.email,
'new_password',
+ '',
'gitlab',
function() {
throw Error('shouldnt work');
@@ -345,6 +346,7 @@ describe('Client.User', function() {
TestHelper.basicClient().emailToLdap(
user.email,
user.password,
+ '',
'unknown_id',
'unknown_pwd',
function() {
@@ -365,6 +367,7 @@ describe('Client.User', function() {
TestHelper.basicClient().ldapToEmail(
user.email,
'new_password',
+ '',
'new_password',
function() {
throw Error('shouldnt work');