summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristopher Speller <crspeller@gmail.com>2016-01-04 15:55:51 -0500
committerChristopher Speller <crspeller@gmail.com>2016-01-04 15:55:51 -0500
commit67db3ca8ce5e78ee55c37a3dbfb8e5d44037a324 (patch)
tree6ffac556a09192e7042d7ab2c184382286338c90
parent07efe758609929e662185199e8c5a273b0fb0007 (diff)
parenta6ae90ac2a74871331707751e823b4746136ff09 (diff)
downloadchat-67db3ca8ce5e78ee55c37a3dbfb8e5d44037a324.tar.gz
chat-67db3ca8ce5e78ee55c37a3dbfb8e5d44037a324.tar.bz2
chat-67db3ca8ce5e78ee55c37a3dbfb8e5d44037a324.zip
Merge pull request #1745 from mattermost/plt-1118
PLT-1118 Add ability to switch between SSO and email account
-rw-r--r--api/templates/signin_change_body.html43
-rw-r--r--api/templates/signin_change_subject.html1
-rw-r--r--api/user.go340
-rw-r--r--api/user_test.go103
-rw-r--r--config/config.json2
-rw-r--r--model/client.go18
-rw-r--r--model/oauth.go7
-rw-r--r--store/sql_user_store.go24
-rw-r--r--store/sql_user_store_test.go31
-rw-r--r--store/store.go1
-rw-r--r--web/react/components/claim/claim_account.jsx53
-rw-r--r--web/react/components/claim/email_to_sso.jsx97
-rw-r--r--web/react/components/claim/sso_to_email.jsx113
-rw-r--r--web/react/components/login.jsx33
-rw-r--r--web/react/components/user_settings/user_settings_security.jsx323
-rw-r--r--web/react/pages/claim_account.jsx19
-rw-r--r--web/react/utils/client.jsx34
-rw-r--r--web/react/utils/constants.jsx2
-rw-r--r--web/templates/claim_account.html16
-rw-r--r--web/web.go221
20 files changed, 1214 insertions, 267 deletions
diff --git a/api/templates/signin_change_body.html b/api/templates/signin_change_body.html
new file mode 100644
index 000000000..5b96df944
--- /dev/null
+++ b/api/templates/signin_change_body.html
@@ -0,0 +1,43 @@
+{{define "signin_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;">You updated your sign-in method</h2>
+ <p>You updated your sign-in method for {{.Props.TeamDisplayName}} on {{ .Props.TeamURL }} to {{.Props.Method}}.<br>If this change wasn't initiated by you, please contact your system administrator.</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/api/templates/signin_change_subject.html b/api/templates/signin_change_subject.html
new file mode 100644
index 000000000..b1d644a28
--- /dev/null
+++ b/api/templates/signin_change_subject.html
@@ -0,0 +1 @@
+{{define "signin_change_subject"}}You updated your sign-in method for {{.Props.TeamDisplayName}} on {{ .ClientCfg.SiteName }}{{end}}
diff --git a/api/user.go b/api/user.go
index 1df8fff73..36a3fd5f9 100644
--- a/api/user.go
+++ b/api/user.go
@@ -47,6 +47,8 @@ func InitUser(r *mux.Router) {
sr.Handle("/logout", ApiUserRequired(logout)).Methods("POST")
sr.Handle("/login_ldap", ApiAppHandler(loginLdap)).Methods("POST")
sr.Handle("/revoke_session", ApiUserRequired(revokeSession)).Methods("POST")
+ sr.Handle("/switch_to_sso", ApiAppHandler(switchToSSO)).Methods("POST")
+ sr.Handle("/switch_to_email", ApiUserRequired(switchToEmail)).Methods("POST")
sr.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST")
@@ -225,6 +227,70 @@ func CreateUser(team *model.Team, user *model.User) (*model.User, *model.AppErro
}
}
+func CreateOAuthUser(c *Context, w http.ResponseWriter, r *http.Request, service string, userData io.ReadCloser, team *model.Team) *model.User {
+ var user *model.User
+ provider := einterfaces.GetOauthProvider(service)
+ if provider == nil {
+ c.Err = model.NewAppError("CreateOAuthUser", service+" oauth not avlailable on this server", "")
+ return nil
+ } else {
+ user = provider.GetUserFromJson(userData)
+ }
+
+ if user == nil {
+ c.Err = model.NewAppError("CreateOAuthUser", "Could not create user out of "+service+" user object", "")
+ return nil
+ }
+
+ suchan := Srv.Store.User().GetByAuth(team.Id, user.AuthData, service)
+ euchan := Srv.Store.User().GetByEmail(team.Id, user.Email)
+
+ if team.Email == "" {
+ team.Email = user.Email
+ if result := <-Srv.Store.Team().Update(team); result.Err != nil {
+ c.Err = result.Err
+ return nil
+ }
+ } else {
+ found := true
+ count := 0
+ for found {
+ if found = IsUsernameTaken(user.Username, team.Id); c.Err != nil {
+ return nil
+ } else if found {
+ user.Username = user.Username + strconv.Itoa(count)
+ count += 1
+ }
+ }
+ }
+
+ if result := <-suchan; result.Err == nil {
+ c.Err = model.NewAppError("signupCompleteOAuth", "This "+service+" account has already been used to sign up for team "+team.DisplayName, "email="+user.Email)
+ return nil
+ }
+
+ if result := <-euchan; result.Err == nil {
+ c.Err = model.NewAppError("signupCompleteOAuth", "Team "+team.DisplayName+" already has a user with the email address attached to your "+service+" account", "email="+user.Email)
+ return nil
+ }
+
+ user.TeamId = team.Id
+ user.EmailVerified = true
+
+ ruser, err := CreateUser(team, user)
+ if err != nil {
+ c.Err = err
+ return nil
+ }
+
+ Login(c, w, r, ruser, "")
+ if c.Err != nil {
+ return nil
+ }
+
+ return ruser
+}
+
func sendWelcomeEmailAndForget(userId, email, teamName, teamDisplayName, siteURL, teamURL string, verified bool) {
go func() {
@@ -335,6 +401,11 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam
} else {
user := result.Data.(*model.User)
+ if len(user.AuthData) != 0 {
+ c.Err = model.NewAppError("LoginByEmail", "Please sign in using "+user.AuthService, "")
+ return nil
+ }
+
if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) {
Login(c, w, r, user, deviceId)
return user
@@ -344,10 +415,36 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam
return nil
}
+func LoginByOAuth(c *Context, w http.ResponseWriter, r *http.Request, service string, userData io.ReadCloser, team *model.Team) *model.User {
+ authData := ""
+ provider := einterfaces.GetOauthProvider(service)
+ if provider == nil {
+ c.Err = model.NewAppError("LoginByOAuth", service+" oauth not avlailable on this server", "")
+ return nil
+ } else {
+ authData = provider.GetAuthDataFromJson(userData)
+ }
+
+ if len(authData) == 0 {
+ c.Err = model.NewAppError("LoginByOAuth", "Could not parse auth data out of "+service+" user object", "")
+ return nil
+ }
+
+ var user *model.User
+ if result := <-Srv.Store.User().GetByAuth(team.Id, authData, service); result.Err != nil {
+ c.Err = result.Err
+ return nil
+ } else {
+ user = result.Data.(*model.User)
+ Login(c, w, r, user, "")
+ return user
+ }
+}
+
func checkUserLoginAttempts(c *Context, user *model.User) bool {
if user.FailedAttempts >= utils.Cfg.ServiceSettings.MaximumLoginAttempts {
c.LogAuditWithUserId(user.Id, "fail")
- c.Err = model.NewAppError("checkUserPassword", "Your account is locked because of too many failed password attempts. Please reset your password.", "user_id="+user.Id)
+ c.Err = model.NewAppError("checkUserLoginAttempts", "Your account is locked because of too many failed password attempts. Please reset your password.", "user_id="+user.Id)
c.Err.StatusCode = http.StatusForbidden
return false
}
@@ -1660,21 +1757,22 @@ func getStatuses(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
-func GetAuthorizationCode(c *Context, w http.ResponseWriter, r *http.Request, teamName, service, redirectUri, loginHint string) {
+func GetAuthorizationCode(c *Context, service, teamName string, props map[string]string, loginHint string) (string, *model.AppError) {
sso := utils.Cfg.GetSSOService(service)
if sso != nil && !sso.Enable {
- c.Err = model.NewAppError("GetAuthorizationCode", "Unsupported OAuth service provider", "service="+service)
- c.Err.StatusCode = http.StatusBadRequest
- return
+ return "", model.NewAppError("GetAuthorizationCode", "Unsupported OAuth service provider", "service="+service)
}
clientId := sso.Id
endpoint := sso.AuthEndpoint
scope := sso.Scope
- stateProps := map[string]string{"team": teamName, "hash": model.HashPassword(clientId)}
- state := b64.StdEncoding.EncodeToString([]byte(model.MapToJson(stateProps)))
+ props["hash"] = model.HashPassword(clientId)
+ props["team"] = teamName
+ state := b64.StdEncoding.EncodeToString([]byte(model.MapToJson(props)))
+
+ redirectUri := c.GetSiteURL() + "/" + service + "/complete"
authUrl := endpoint + "?response_type=code&client_id=" + clientId + "&redirect_uri=" + url.QueryEscape(redirectUri) + "&state=" + url.QueryEscape(state)
@@ -1686,18 +1784,18 @@ func GetAuthorizationCode(c *Context, w http.ResponseWriter, r *http.Request, te
authUrl += "&login_hint=" + utils.UrlEncode(loginHint)
}
- http.Redirect(w, r, authUrl, http.StatusFound)
+ return authUrl, nil
}
-func AuthorizeOAuthUser(service, code, state, redirectUri string) (io.ReadCloser, *model.Team, *model.AppError) {
+func AuthorizeOAuthUser(service, code, state, redirectUri string) (io.ReadCloser, *model.Team, map[string]string, *model.AppError) {
sso := utils.Cfg.GetSSOService(service)
if sso == nil || !sso.Enable {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Unsupported OAuth service provider", "service="+service)
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Unsupported OAuth service provider", "service="+service)
}
stateStr := ""
if b, err := b64.StdEncoding.DecodeString(state); err != nil {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Invalid state", err.Error())
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Invalid state", err.Error())
} else {
stateStr = string(b)
}
@@ -1705,12 +1803,13 @@ func AuthorizeOAuthUser(service, code, state, redirectUri string) (io.ReadCloser
stateProps := model.MapFromJson(strings.NewReader(stateStr))
if !model.ComparePassword(stateProps["hash"], sso.Id) {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Invalid state", "")
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Invalid state", "")
}
- teamName := stateProps["team"]
- if len(teamName) == 0 {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Invalid state; missing team name", "")
+ ok := true
+ teamName := ""
+ if teamName, ok = stateProps["team"]; !ok {
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Invalid state; missing team name", "")
}
tchan := Srv.Store.Team().GetByName(teamName)
@@ -1730,20 +1829,20 @@ func AuthorizeOAuthUser(service, code, state, redirectUri string) (io.ReadCloser
var ar *model.AccessResponse
if resp, err := client.Do(req); err != nil {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Token request failed", err.Error())
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Token request failed", err.Error())
} else {
ar = model.AccessResponseFromJson(resp.Body)
if ar == nil {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Bad response from token request", "")
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Bad response from token request", "")
}
}
if strings.ToLower(ar.TokenType) != model.ACCESS_TOKEN_TYPE {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Bad token type", "token_type="+ar.TokenType)
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Bad token type", "token_type="+ar.TokenType)
}
if len(ar.AccessToken) == 0 {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Missing access token", "")
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Missing access token", "")
}
p = url.Values{}
@@ -1755,12 +1854,12 @@ func AuthorizeOAuthUser(service, code, state, redirectUri string) (io.ReadCloser
req.Header.Set("Authorization", "Bearer "+ar.AccessToken)
if resp, err := client.Do(req); err != nil {
- return nil, nil, model.NewAppError("AuthorizeOAuthUser", "Token request to "+service+" failed", err.Error())
+ return nil, nil, nil, model.NewAppError("AuthorizeOAuthUser", "Token request to "+service+" failed", err.Error())
} else {
if result := <-tchan; result.Err != nil {
- return nil, nil, result.Err
+ return nil, nil, nil, result.Err
} else {
- return resp.Body, result.Data.(*model.Team), nil
+ return resp.Body, result.Data.(*model.Team), stateProps, nil
}
}
@@ -1780,3 +1879,200 @@ func IsUsernameTaken(name string, teamId string) bool {
return false
}
+
+func switchToSSO(c *Context, w http.ResponseWriter, r *http.Request) {
+ props := model.MapFromJson(r.Body)
+
+ password := props["password"]
+ if len(password) == 0 {
+ c.SetInvalidParam("switchToSSO", "password")
+ return
+ }
+
+ teamName := props["team_name"]
+ if len(teamName) == 0 {
+ c.SetInvalidParam("switchToSSO", "team_name")
+ return
+ }
+
+ service := props["service"]
+ if len(service) == 0 {
+ c.SetInvalidParam("switchToSSO", "service")
+ return
+ }
+
+ email := props["email"]
+ if len(email) == 0 {
+ c.SetInvalidParam("switchToSSO", "email")
+ return
+ }
+
+ c.LogAudit("attempt")
+
+ var team *model.Team
+ if result := <-Srv.Store.Team().GetByName(teamName); result.Err != nil {
+ c.LogAudit("fail - couldn't get team")
+ c.Err = result.Err
+ return
+ } else {
+ team = result.Data.(*model.Team)
+ }
+
+ var user *model.User
+ if result := <-Srv.Store.User().GetByEmail(team.Id, email); result.Err != nil {
+ c.LogAudit("fail - couldn't get user")
+ c.Err = result.Err
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ if !checkUserLoginAttempts(c, user) || !checkUserPassword(c, user, password) {
+ c.LogAuditWithUserId(user.Id, "fail - invalid password")
+ return
+ }
+
+ stateProps := map[string]string{}
+ stateProps["action"] = model.OAUTH_ACTION_EMAIL_TO_SSO
+ stateProps["email"] = email
+
+ m := map[string]string{}
+ if authUrl, err := GetAuthorizationCode(c, service, teamName, stateProps, ""); err != nil {
+ c.LogAuditWithUserId(user.Id, "fail - oauth issue")
+ c.Err = err
+ return
+ } else {
+ m["follow_link"] = authUrl
+ }
+
+ c.LogAuditWithUserId(user.Id, "success")
+ w.Write([]byte(model.MapToJson(m)))
+}
+
+func CompleteSwitchWithOAuth(c *Context, w http.ResponseWriter, r *http.Request, service string, userData io.ReadCloser, team *model.Team, email string) {
+ authData := ""
+ provider := einterfaces.GetOauthProvider(service)
+ if provider == nil {
+ c.Err = model.NewAppError("CompleteClaimWithOAuth", service+" oauth not avlailable on this server", "")
+ return
+ } else {
+ authData = provider.GetAuthDataFromJson(userData)
+ }
+
+ if len(authData) == 0 {
+ c.Err = model.NewAppError("CompleteClaimWithOAuth", "Could not parse auth data out of "+service+" user object", "")
+ return
+ }
+
+ if len(email) == 0 {
+ c.Err = model.NewAppError("CompleteClaimWithOAuth", "Blank email", "")
+ return
+ }
+
+ var user *model.User
+ if result := <-Srv.Store.User().GetByEmail(team.Id, email); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ RevokeAllSession(c, user.Id)
+ if c.Err != nil {
+ return
+ }
+
+ if result := <-Srv.Store.User().UpdateAuthData(user.Id, service, authData); result.Err != nil {
+ c.Err = result.Err
+ return
+ }
+
+ sendSignInChangeEmailAndForget(user.Email, team.DisplayName, c.GetSiteURL()+"/"+team.Name, c.GetSiteURL(), strings.Title(service)+" SSO")
+}
+
+func switchToEmail(c *Context, w http.ResponseWriter, r *http.Request) {
+ props := model.MapFromJson(r.Body)
+
+ password := props["password"]
+ if len(password) == 0 {
+ c.SetInvalidParam("switchToEmail", "password")
+ return
+ }
+
+ teamName := props["team_name"]
+ if len(teamName) == 0 {
+ c.SetInvalidParam("switchToEmail", "team_name")
+ return
+ }
+
+ email := props["email"]
+ if len(email) == 0 {
+ c.SetInvalidParam("switchToEmail", "email")
+ return
+ }
+
+ c.LogAudit("attempt")
+
+ var team *model.Team
+ if result := <-Srv.Store.Team().GetByName(teamName); result.Err != nil {
+ c.LogAudit("fail - couldn't get team")
+ c.Err = result.Err
+ return
+ } else {
+ team = result.Data.(*model.Team)
+ }
+
+ var user *model.User
+ if result := <-Srv.Store.User().GetByEmail(team.Id, email); result.Err != nil {
+ c.LogAudit("fail - couldn't get user")
+ c.Err = result.Err
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ if user.Id != c.Session.UserId {
+ c.LogAudit("fail - user ids didn't match")
+ c.Err = model.NewAppError("switchToEmail", "Update password failed because context user_id did not match provided user's id", "")
+ c.Err.StatusCode = http.StatusForbidden
+ return
+ }
+
+ if result := <-Srv.Store.User().UpdatePassword(c.Session.UserId, model.HashPassword(password)); result.Err != nil {
+ c.LogAudit("fail - database issue")
+ c.Err = result.Err
+ return
+ }
+
+ sendSignInChangeEmailAndForget(user.Email, team.DisplayName, c.GetSiteURL()+"/"+team.Name, c.GetSiteURL(), "email and password")
+
+ RevokeAllSession(c, c.Session.UserId)
+ if c.Err != nil {
+ return
+ }
+
+ m := map[string]string{}
+ m["follow_link"] = c.GetTeamURL() + "/login?extra=signin_change"
+
+ c.LogAudit("success")
+ w.Write([]byte(model.MapToJson(m)))
+}
+
+func sendSignInChangeEmailAndForget(email, teamDisplayName, teamURL, siteURL, method string) {
+ go func() {
+
+ subjectPage := NewServerTemplatePage("signin_change_subject")
+ subjectPage.Props["SiteURL"] = siteURL
+ subjectPage.Props["TeamDisplayName"] = teamDisplayName
+ bodyPage := NewServerTemplatePage("signin_change_body")
+ bodyPage.Props["SiteURL"] = siteURL
+ bodyPage.Props["TeamDisplayName"] = teamDisplayName
+ bodyPage.Props["TeamURL"] = teamURL
+ bodyPage.Props["Method"] = method
+
+ if err := utils.SendMail(email, subjectPage.Render(), bodyPage.Render()); err != nil {
+ l4g.Error("Failed to send update password email successfully err=%v", err)
+ }
+
+ }()
+}
diff --git a/api/user_test.go b/api/user_test.go
index 731450321..ffa96cb66 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -1085,3 +1085,106 @@ func TestStatuses(t *testing.T) {
}
}
+
+func TestSwitchToSSO(t *testing.T) {
+ Setup()
+
+ team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ rteam, _ := Client.CreateTeam(&team)
+
+ user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "corey+test@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ ruser := Client.Must(Client.CreateUser(&user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(ruser.Id))
+
+ m := map[string]string{}
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - empty data")
+ }
+
+ m["password"] = "pwd"
+ _, err := Client.SwitchToSSO(m)
+ if err == nil {
+ t.Fatal("should have failed - missing team_name, service, email")
+ }
+
+ m["team_name"] = team.Name
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - missing service, email")
+ }
+
+ m["service"] = "someservice"
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - missing email")
+ }
+
+ m["team_name"] = "junk"
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - bad team name")
+ }
+
+ m["team_name"] = team.Name
+ m["email"] = "junk"
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - bad email")
+ }
+
+ m["email"] = ruser.Email
+ m["password"] = "junk"
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - bad password")
+ }
+}
+
+func TestSwitchToEmail(t *testing.T) {
+ Setup()
+
+ team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ rteam, _ := Client.CreateTeam(&team)
+
+ user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "corey+test@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ ruser := Client.Must(Client.CreateUser(&user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(ruser.Id))
+
+ user2 := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "corey+test@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ ruser2 := Client.Must(Client.CreateUser(&user2, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(ruser2.Id))
+
+ m := map[string]string{}
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - not logged in")
+ }
+
+ Client.LoginByEmail(team.Name, user.Email, user.Password)
+
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - empty data")
+ }
+
+ m["password"] = "pwd"
+ _, err := Client.SwitchToSSO(m)
+ if err == nil {
+ t.Fatal("should have failed - missing team_name, service, email")
+ }
+
+ m["team_name"] = team.Name
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - missing email")
+ }
+
+ m["email"] = ruser.Email
+ m["team_name"] = "junk"
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - bad team name")
+ }
+
+ m["team_name"] = team.Name
+ m["email"] = "junk"
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - bad email")
+ }
+
+ m["email"] = ruser2.Email
+ if _, err := Client.SwitchToSSO(m); err == nil {
+ t.Fatal("should have failed - wrong user")
+ }
+}
diff --git a/config/config.json b/config/config.json
index 516b96dab..5469608f8 100644
--- a/config/config.json
+++ b/config/config.json
@@ -100,4 +100,4 @@
"TokenEndpoint": "",
"UserApiEndpoint": ""
}
-} \ No newline at end of file
+}
diff --git a/model/client.go b/model/client.go
index b9b97dedc..f1773f3c7 100644
--- a/model/client.go
+++ b/model/client.go
@@ -349,6 +349,24 @@ func (c *Client) GetSessions(id string) (*Result, *AppError) {
}
}
+func (c *Client) SwitchToSSO(m map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/users/switch_to_sso", MapToJson(m)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil
+ }
+}
+
+func (c *Client) SwitchToEmail(m map[string]string) (*Result, *AppError) {
+ if r, err := c.DoApiPost("/users/switch_to_email", MapToJson(m)); err != nil {
+ return nil, err
+ } else {
+ return &Result{r.Header.Get(HEADER_REQUEST_ID),
+ r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil
+ }
+}
+
func (c *Client) Command(channelId string, command string, suggest bool) (*Result, *AppError) {
m := make(map[string]string)
m["command"] = command
diff --git a/model/oauth.go b/model/oauth.go
index 448fd9dc9..8336e26ba 100644
--- a/model/oauth.go
+++ b/model/oauth.go
@@ -10,6 +10,13 @@ import (
"unicode/utf8"
)
+const (
+ OAUTH_ACTION_SIGNUP = "signup"
+ OAUTH_ACTION_LOGIN = "login"
+ OAUTH_ACTION_EMAIL_TO_SSO = "email_to_sso"
+ OAUTH_ACTION_SSO_TO_EMAIL = "sso_to_email"
+)
+
type OAuthApp struct {
Id string `json:"id"`
CreatorId string `json:"creator_id"`
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 82a2ccd05..88c4f954b 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -266,7 +266,7 @@ func (us SqlUserStore) UpdatePassword(userId, hashedPassword string) StoreChanne
updateAt := model.GetMillis()
- if _, err := us.GetMaster().Exec("UPDATE Users SET Password = :Password, LastPasswordUpdate = :LastPasswordUpdate, UpdateAt = :UpdateAt, FailedAttempts = 0 WHERE Id = :UserId AND AuthData = ''", map[string]interface{}{"Password": hashedPassword, "LastPasswordUpdate": updateAt, "UpdateAt": updateAt, "UserId": userId}); err != nil {
+ if _, err := us.GetMaster().Exec("UPDATE Users SET Password = :Password, LastPasswordUpdate = :LastPasswordUpdate, UpdateAt = :UpdateAt, AuthData = '', AuthService = '', FailedAttempts = 0 WHERE Id = :UserId", map[string]interface{}{"Password": hashedPassword, "LastPasswordUpdate": updateAt, "UpdateAt": updateAt, "UserId": userId}); err != nil {
result.Err = model.NewAppError("SqlUserStore.UpdatePassword", "We couldn't update the user password", "id="+userId+", "+err.Error())
} else {
result.Data = userId
@@ -298,6 +298,28 @@ func (us SqlUserStore) UpdateFailedPasswordAttempts(userId string, attempts int)
return storeChannel
}
+func (us SqlUserStore) UpdateAuthData(userId, service, authData string) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ updateAt := model.GetMillis()
+
+ if _, err := us.GetMaster().Exec("UPDATE Users SET Password = '', LastPasswordUpdate = :LastPasswordUpdate, UpdateAt = :UpdateAt, FailedAttempts = 0, AuthService = :AuthService, AuthData = :AuthData WHERE Id = :UserId", map[string]interface{}{"LastPasswordUpdate": updateAt, "UpdateAt": updateAt, "UserId": userId, "AuthService": service, "AuthData": authData}); err != nil {
+ result.Err = model.NewAppError("SqlUserStore.UpdateAuthData", "We couldn't update the auth data", "id="+userId+", "+err.Error())
+ } else {
+ result.Data = userId
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (us SqlUserStore) Get(id string) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go
index dd08438f1..d1ee5e647 100644
--- a/store/sql_user_store_test.go
+++ b/store/sql_user_store_test.go
@@ -390,3 +390,34 @@ func TestUserStoreDelete(t *testing.T) {
t.Fatal(err)
}
}
+
+func TestUserStoreUpdateAuthData(t *testing.T) {
+ Setup()
+
+ u1 := model.User{}
+ u1.TeamId = model.NewId()
+ u1.Email = model.NewId()
+ Must(store.User().Save(&u1))
+
+ service := "someservice"
+ authData := "1"
+
+ if err := (<-store.User().UpdateAuthData(u1.Id, service, authData)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ if r1 := <-store.User().GetByEmail(u1.TeamId, u1.Email); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ user := r1.Data.(*model.User)
+ if user.AuthService != service {
+ t.Fatal("AuthService was not updated correctly")
+ }
+ if user.AuthData != authData {
+ t.Fatal("AuthData was not updated correctly")
+ }
+ if user.Password != "" {
+ t.Fatal("Password was not cleared properly")
+ }
+ }
+}
diff --git a/store/store.go b/store/store.go
index 682195148..8e03c8ee7 100644
--- a/store/store.go
+++ b/store/store.go
@@ -111,6 +111,7 @@ type UserStore interface {
UpdateLastActivityAt(userId string, time int64) StoreChannel
UpdateUserAndSessionActivity(userId string, sessionId string, time int64) StoreChannel
UpdatePassword(userId, newPassword string) StoreChannel
+ UpdateAuthData(userId, service, authData string) StoreChannel
Get(id string) StoreChannel
GetProfiles(teamId string) StoreChannel
GetByEmail(teamId string, email string) StoreChannel
diff --git a/web/react/components/claim/claim_account.jsx b/web/react/components/claim/claim_account.jsx
new file mode 100644
index 000000000..f38f558db
--- /dev/null
+++ b/web/react/components/claim/claim_account.jsx
@@ -0,0 +1,53 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import EmailToSSO from './email_to_sso.jsx';
+import SSOToEmail from './sso_to_email.jsx';
+
+export default class ClaimAccount extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ }
+ render() {
+ let content;
+ if (this.props.email === '') {
+ content = <p>{'No email specified.'}</p>;
+ } else if (this.props.currentType === '' && this.props.newType !== '') {
+ content = (
+ <EmailToSSO
+ email={this.props.email}
+ type={this.props.newType}
+ teamName={this.props.teamName}
+ teamDisplayName={this.props.teamDisplayName}
+ />
+ );
+ } else {
+ content = (
+ <SSOToEmail
+ email={this.props.email}
+ currentType={this.props.currentType}
+ teamName={this.props.teamName}
+ teamDisplayName={this.props.teamDisplayName}
+ />
+ );
+ }
+
+ return (
+ <div>
+ {content}
+ </div>
+ );
+ }
+}
+
+ClaimAccount.defaultProps = {
+};
+ClaimAccount.propTypes = {
+ currentType: React.PropTypes.string.isRequired,
+ newType: React.PropTypes.string.isRequired,
+ email: React.PropTypes.string.isRequired,
+ teamName: React.PropTypes.string.isRequired,
+ teamDisplayName: React.PropTypes.string.isRequired
+};
diff --git a/web/react/components/claim/email_to_sso.jsx b/web/react/components/claim/email_to_sso.jsx
new file mode 100644
index 000000000..ac0cf876b
--- /dev/null
+++ b/web/react/components/claim/email_to_sso.jsx
@@ -0,0 +1,97 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import * as Utils from '../../utils/utils.jsx';
+import * as Client from '../../utils/client.jsx';
+
+export default class EmailToSSO extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.submit = this.submit.bind(this);
+
+ this.state = {};
+ }
+ submit(e) {
+ e.preventDefault();
+ var state = {};
+
+ var password = ReactDOM.findDOMNode(this.refs.password).value.trim();
+ if (!password) {
+ state.error = 'Please enter your password.';
+ this.setState(state);
+ return;
+ }
+
+ state.error = null;
+ this.setState(state);
+
+ var postData = {};
+ postData.password = password;
+ postData.email = this.props.email;
+ postData.team_name = this.props.teamName;
+ postData.service = this.props.type;
+
+ Client.switchToSSO(postData,
+ (data) => {
+ if (data.follow_link) {
+ window.location.href = data.follow_link;
+ }
+ },
+ (error) => {
+ this.setState({error});
+ }
+ );
+ }
+ render() {
+ var error = null;
+ if (this.state.error) {
+ error = <div className='form-group has-error'><label className='control-label'>{this.state.error}</label></div>;
+ }
+
+ var formClass = 'form-group';
+ if (error) {
+ formClass += ' has-error';
+ }
+
+ const uiType = Utils.toTitleCase(this.props.type) + ' SSO';
+
+ return (
+ <div className='col-sm-12'>
+ <div className='signup-team__container'>
+ <h3>{'Switch Email/Password Account to ' + uiType}</h3>
+ <form onSubmit={this.submit}>
+ <p>{'Upon claiming your account, you will only be able to login with ' + Utils.toTitleCase(this.props.type) + ' SSO.'}</p>
+ <p>{'Enter the password for your ' + this.props.teamDisplayName + ' ' + global.window.mm_config.SiteName + ' account.'}</p>
+ <div className={formClass}>
+ <input
+ type='password'
+ className='form-control'
+ name='password'
+ ref='password'
+ placeholder='Password'
+ spellCheck='false'
+ />
+ </div>
+ {error}
+ <button
+ type='submit'
+ className='btn btn-primary'
+ >
+ {'Switch account to ' + uiType}
+ </button>
+ </form>
+ </div>
+ </div>
+ );
+ }
+}
+
+EmailToSSO.defaultProps = {
+};
+EmailToSSO.propTypes = {
+ type: React.PropTypes.string.isRequired,
+ email: React.PropTypes.string.isRequired,
+ teamName: React.PropTypes.string.isRequired,
+ teamDisplayName: React.PropTypes.string.isRequired
+};
diff --git a/web/react/components/claim/sso_to_email.jsx b/web/react/components/claim/sso_to_email.jsx
new file mode 100644
index 000000000..0868b7f2f
--- /dev/null
+++ b/web/react/components/claim/sso_to_email.jsx
@@ -0,0 +1,113 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import * as Utils from '../../utils/utils.jsx';
+import * as Client from '../../utils/client.jsx';
+
+export default class SSOToEmail extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.submit = this.submit.bind(this);
+
+ this.state = {};
+ }
+ submit(e) {
+ e.preventDefault();
+ const state = {};
+
+ const password = ReactDOM.findDOMNode(this.refs.password).value.trim();
+ if (!password) {
+ state.error = 'Please enter a password.';
+ this.setState(state);
+ return;
+ }
+
+ const confirmPassword = ReactDOM.findDOMNode(this.refs.passwordconfirm).value.trim();
+ if (!confirmPassword || password !== confirmPassword) {
+ state.error = 'Passwords do not match.';
+ this.setState(state);
+ return;
+ }
+
+ state.error = null;
+ this.setState(state);
+
+ var postData = {};
+ postData.password = password;
+ postData.email = this.props.email;
+ postData.team_name = this.props.teamName;
+
+ Client.switchToEmail(postData,
+ (data) => {
+ if (data.follow_link) {
+ window.location.href = data.follow_link;
+ }
+ },
+ (error) => {
+ this.setState({error});
+ }
+ );
+ }
+ render() {
+ var error = null;
+ if (this.state.error) {
+ error = <div className='form-group has-error'><label className='control-label'>{this.state.error}</label></div>;
+ }
+
+ var formClass = 'form-group';
+ if (error) {
+ formClass += ' has-error';
+ }
+
+ const uiType = Utils.toTitleCase(this.props.currentType) + ' SSO';
+
+ return (
+ <div className='col-sm-12'>
+ <div className='signup-team__container'>
+ <h3>{'Switch ' + uiType + ' Account to Email'}</h3>
+ <form onSubmit={this.submit}>
+ <p>{'Upon changing your account type, you will only be able to login with your email and password.'}</p>
+ <p>{'Enter a new password for your ' + this.props.teamDisplayName + ' ' + global.window.mm_config.SiteName + ' account.'}</p>
+ <div className={formClass}>
+ <input
+ type='password'
+ className='form-control'
+ name='password'
+ ref='password'
+ placeholder='New Password'
+ spellCheck='false'
+ />
+ </div>
+ <div className={formClass}>
+ <input
+ type='password'
+ className='form-control'
+ name='passwordconfirm'
+ ref='passwordconfirm'
+ placeholder='Confirm Password'
+ spellCheck='false'
+ />
+ </div>
+ {error}
+ <button
+ type='submit'
+ className='btn btn-primary'
+ >
+ {'Switch ' + uiType + ' account to email and password'}
+ </button>
+ </form>
+ </div>
+ </div>
+ );
+ }
+}
+
+SSOToEmail.defaultProps = {
+};
+SSOToEmail.propTypes = {
+ currentType: React.PropTypes.string.isRequired,
+ email: React.PropTypes.string.isRequired,
+ teamName: React.PropTypes.string.isRequired,
+ teamDisplayName: React.PropTypes.string.isRequired
+};
diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx
index 9afaa8b0d..1d9b3e906 100644
--- a/web/react/components/login.jsx
+++ b/web/react/components/login.jsx
@@ -1,10 +1,12 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import * as Utils from '../utils/utils.jsx';
import LoginEmail from './login_email.jsx';
import LoginLdap from './login_ldap.jsx';
+import * as Utils from '../utils/utils.jsx';
+import Constants from '../utils/constants.jsx';
+
export default class Login extends React.Component {
constructor(props) {
super(props);
@@ -40,15 +42,24 @@ export default class Login extends React.Component {
);
}
- const verifiedParam = Utils.getUrlParameter('verified');
- let verifiedBox = '';
- if (verifiedParam) {
- verifiedBox = (
- <div className='alert alert-success'>
- <i className='fa fa-check' />
- {' Email Verified'}
- </div>
- );
+ const extraParam = Utils.getUrlParameter('extra');
+ let extraBox = '';
+ if (extraParam) {
+ let msg;
+ if (extraParam === Constants.SIGNIN_CHANGE) {
+ msg = ' Sign-in method changed successfully';
+ } else if (extraParam === Constants.SIGNIN_VERIFIED) {
+ msg = ' Email Verified';
+ }
+
+ if (msg != null) {
+ extraBox = (
+ <div className='alert alert-success'>
+ <i className='fa fa-check' />
+ {msg}
+ </div>
+ );
+ }
}
let emailSignup;
@@ -124,7 +135,7 @@ export default class Login extends React.Component {
<h5 className='margin--less'>{'Sign in to:'}</h5>
<h2 className='signup-team__name'>{teamDisplayName}</h2>
<h2 className='signup-team__subdomain'>{'on '}{global.window.mm_config.SiteName}</h2>
- {verifiedBox}
+ {extraBox}
{loginMessage}
{emailSignup}
{ldapLogin}
diff --git a/web/react/components/user_settings/user_settings_security.jsx b/web/react/components/user_settings/user_settings_security.jsx
index fa2fecf07..d9c5f58a9 100644
--- a/web/react/components/user_settings/user_settings_security.jsx
+++ b/web/react/components/user_settings/user_settings_security.jsx
@@ -6,6 +6,9 @@ import SettingItemMax from '../setting_item_max.jsx';
import AccessHistoryModal from '../access_history_modal.jsx';
import ActivityLogModal from '../activity_log_modal.jsx';
import ToggleModalButton from '../toggle_modal_button.jsx';
+
+import TeamStore from '../../stores/team_store.jsx';
+
import * as Client from '../../utils/client.jsx';
import * as AsyncClient from '../../utils/async_client.jsx';
import Constants from '../../utils/constants.jsx';
@@ -18,9 +21,19 @@ export default class SecurityTab extends React.Component {
this.updateCurrentPassword = this.updateCurrentPassword.bind(this);
this.updateNewPassword = this.updateNewPassword.bind(this);
this.updateConfirmPassword = this.updateConfirmPassword.bind(this);
- this.setupInitialState = this.setupInitialState.bind(this);
+ this.getDefaultState = this.getDefaultState.bind(this);
+ this.createPasswordSection = this.createPasswordSection.bind(this);
+ this.createSignInSection = this.createSignInSection.bind(this);
- this.state = this.setupInitialState();
+ this.state = this.getDefaultState();
+ }
+ getDefaultState() {
+ return {
+ currentPassword: '',
+ newPassword: '',
+ confirmPassword: '',
+ authService: this.props.user.auth_service
+ };
}
submitPassword(e) {
e.preventDefault();
@@ -51,13 +64,13 @@ export default class SecurityTab extends React.Component {
data.new_password = newPassword;
Client.updatePassword(data,
- function success() {
+ () => {
this.props.updateSection('');
AsyncClient.getMe();
- this.setState(this.setupInitialState());
- }.bind(this),
- function fail(err) {
- var state = this.setupInitialState();
+ this.setState(this.getDefaultState());
+ },
+ (err) => {
+ var state = this.getDefaultState();
if (err.message) {
state.serverError = err.message;
} else {
@@ -65,7 +78,7 @@ export default class SecurityTab extends React.Component {
}
state.passwordError = '';
this.setState(state);
- }.bind(this)
+ }
);
}
updateCurrentPassword(e) {
@@ -77,86 +90,60 @@ export default class SecurityTab extends React.Component {
updateConfirmPassword(e) {
this.setState({confirmPassword: e.target.value});
}
- setupInitialState() {
- return {currentPassword: '', newPassword: '', confirmPassword: ''};
- }
- render() {
- var serverError;
- if (this.state.serverError) {
- serverError = this.state.serverError;
- }
- var passwordError;
- if (this.state.passwordError) {
- passwordError = this.state.passwordError;
- }
+ createPasswordSection() {
+ let updateSectionStatus;
- var updateSectionStatus;
- var passwordSection;
- if (this.props.activeSection === 'password') {
- var inputs = [];
- var submit = null;
-
- if (this.props.user.auth_service === '') {
- inputs.push(
- <div
- key='currentPasswordUpdateForm'
- className='form-group'
- >
- <label className='col-sm-5 control-label'>Current Password</label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='password'
- onChange={this.updateCurrentPassword}
- value={this.state.currentPassword}
- />
- </div>
- </div>
- );
- inputs.push(
- <div
- key='newPasswordUpdateForm'
- className='form-group'
- >
- <label className='col-sm-5 control-label'>New Password</label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='password'
- onChange={this.updateNewPassword}
- value={this.state.newPassword}
- />
- </div>
+ if (this.props.activeSection === 'password' && this.props.user.auth_service === '') {
+ const inputs = [];
+
+ inputs.push(
+ <div
+ key='currentPasswordUpdateForm'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>{'Current Password'}</label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='password'
+ onChange={this.updateCurrentPassword}
+ value={this.state.currentPassword}
+ />
</div>
- );
- inputs.push(
- <div
- key='retypeNewPasswordUpdateForm'
- className='form-group'
- >
- <label className='col-sm-5 control-label'>Retype New Password</label>
- <div className='col-sm-7'>
- <input
- className='form-control'
- type='password'
- onChange={this.updateConfirmPassword}
- value={this.state.confirmPassword}
- />
- </div>
+ </div>
+ );
+ inputs.push(
+ <div
+ key='newPasswordUpdateForm'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>{'New Password'}</label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='password'
+ onChange={this.updateNewPassword}
+ value={this.state.newPassword}
+ />
</div>
- );
-
- submit = this.submitPassword;
- } else {
- inputs.push(
- <div
- key='oauthPasswordInfo'
- className='form-group'
- >
- <label className='col-sm-12'>Log in occurs through GitLab. Please see your GitLab account settings page to update your password.</label>
+ </div>
+ );
+ inputs.push(
+ <div
+ key='retypeNewPasswordUpdateForm'
+ className='form-group'
+ >
+ <label className='col-sm-5 control-label'>{'Retype New Password'}</label>
+ <div className='col-sm-7'>
+ <input
+ className='form-control'
+ type='password'
+ onChange={this.updateConfirmPassword}
+ value={this.state.confirmPassword}
+ />
</div>
- );
- }
+ </div>
+ );
updateSectionStatus = function resetSection(e) {
this.props.updateSection('');
@@ -164,51 +151,157 @@ export default class SecurityTab extends React.Component {
e.preventDefault();
}.bind(this);
- passwordSection = (
+ return (
<SettingItemMax
title='Password'
inputs={inputs}
- submit={submit}
- server_error={serverError}
- client_error={passwordError}
+ submit={this.submitPassword}
+ server_error={this.state.serverError}
+ client_error={this.state.passwordError}
updateSection={updateSectionStatus}
/>
);
- } else {
- var describe;
- if (this.props.user.auth_service === '') {
- var d = new Date(this.props.user.last_password_update);
- var hour = '12';
- if (d.getHours() % 12) {
- hour = String(d.getHours() % 12);
- }
- var min = String(d.getMinutes());
- if (d.getMinutes() < 10) {
- min = '0' + d.getMinutes();
- }
- var timeOfDay = ' am';
- if (d.getHours() >= 12) {
- timeOfDay = ' pm';
- }
+ }
+
+ var describe;
+ var d = new Date(this.props.user.last_password_update);
+ var hour = '12';
+ if (d.getHours() % 12) {
+ hour = String(d.getHours() % 12);
+ }
+ var min = String(d.getMinutes());
+ if (d.getMinutes() < 10) {
+ min = '0' + d.getMinutes();
+ }
+ var timeOfDay = ' am';
+ if (d.getHours() >= 12) {
+ timeOfDay = ' pm';
+ }
- describe = 'Last updated ' + Constants.MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear() + ' at ' + hour + ':' + min + timeOfDay;
- } else {
- describe = 'Log in done through GitLab';
+ describe = 'Last updated ' + Constants.MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear() + ' at ' + hour + ':' + min + timeOfDay;
+
+ updateSectionStatus = function updateSection() {
+ this.props.updateSection('password');
+ }.bind(this);
+
+ return (
+ <SettingItemMin
+ title='Password'
+ describe={describe}
+ updateSection={updateSectionStatus}
+ />
+ );
+ }
+ createSignInSection() {
+ let updateSectionStatus;
+ const user = this.props.user;
+
+ if (this.props.activeSection === 'signin') {
+ const inputs = [];
+ const teamName = TeamStore.getCurrent().name;
+
+ let emailOption;
+ if (global.window.mm_config.EnableSignUpWithEmail === 'true' && user.auth_service !== '') {
+ emailOption = (
+ <div>
+ <a
+ className='btn btn-primary'
+ href={'/' + teamName + '/claim?email=' + user.email}
+ >
+ {'Switch to using email and password'}
+ </a>
+ <br/>
+ </div>
+ );
}
- updateSectionStatus = function updateSection() {
- this.props.updateSection('password');
+ let gitlabOption;
+ if (global.window.mm_config.EnableSignUpWithGitLab === 'true' && user.auth_service === '') {
+ gitlabOption = (
+ <div>
+ <a
+ className='btn btn-primary'
+ href={'/' + teamName + '/claim?email=' + user.email + '&new_type=' + Constants.GITLAB_SERVICE}
+ >
+ {'Switch to using GitLab SSO'}
+ </a>
+ <br/>
+ </div>
+ );
+ }
+
+ let googleOption;
+ if (global.window.mm_config.EnableSignUpWithGoogle === 'true' && user.auth_service === '') {
+ googleOption = (
+ <div>
+ <a
+ className='btn btn-primary'
+ href={'/' + teamName + '/claim?email=' + user.email + '&new_type=' + Constants.GOOGLE_SERVICE}
+ >
+ {'Switch to using Google SSO'}
+ </a>
+ <br/>
+ </div>
+ );
+ }
+
+ inputs.push(
+ <div key='userSignInOption'>
+ {emailOption}
+ {gitlabOption}
+ <br/>
+ {googleOption}
+ </div>
+ );
+
+ updateSectionStatus = function updateSection(e) {
+ this.props.updateSection('');
+ this.setState({serverError: null});
+ e.preventDefault();
}.bind(this);
- passwordSection = (
- <SettingItemMin
- title='Password'
- describe={describe}
+ const extraInfo = <span>{'You may only have one sign-in method at a time. Switching sign-in method will send an email notifying you if the change was successful.'}</span>;
+
+ return (
+ <SettingItemMax
+ title='Sign-in Method'
+ extraInfo={extraInfo}
+ inputs={inputs}
+ server_error={this.state.serverError}
updateSection={updateSectionStatus}
/>
);
}
+ updateSectionStatus = function updateSection() {
+ this.props.updateSection('signin');
+ }.bind(this);
+
+ let describe = 'Email and Password';
+ if (this.props.user.auth_service === Constants.GITLAB_SERVICE) {
+ describe = 'GitLab SSO';
+ }
+
+ return (
+ <SettingItemMin
+ title='Sign-in Method'
+ describe={describe}
+ updateSection={updateSectionStatus}
+ />
+ );
+ }
+ render() {
+ const passwordSection = this.createPasswordSection();
+ let signInSection;
+
+ let numMethods = 0;
+ numMethods = global.window.mm_config.EnableSignUpWithGitLab === 'true' ? numMethods + 1 : numMethods;
+ numMethods = global.window.mm_config.EnableSignUpWithGoogle === 'true' ? numMethods + 1 : numMethods;
+
+ if (global.window.mm_config.EnableSignUpWithEmail && numMethods > 0) {
+ signInSection = this.createSignInSection();
+ }
+
return (
<div>
<div className='modal-header'>
@@ -233,9 +326,11 @@ export default class SecurityTab extends React.Component {
</h4>
</div>
<div className='user-settings'>
- <h3 className='tab-header'>Security Settings</h3>
+ <h3 className='tab-header'>{'Security Settings'}</h3>
<div className='divider-dark first'/>
{passwordSection}
+ <div className='divider-light'/>
+ {signInSection}
<div className='divider-dark'/>
<br></br>
<ToggleModalButton
diff --git a/web/react/pages/claim_account.jsx b/web/react/pages/claim_account.jsx
new file mode 100644
index 000000000..bca203d96
--- /dev/null
+++ b/web/react/pages/claim_account.jsx
@@ -0,0 +1,19 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import ClaimAccount from '../components/claim/claim_account.jsx';
+
+function setupClaimAccountPage(props) {
+ ReactDOM.render(
+ <ClaimAccount
+ email={props.Email}
+ currentType={props.CurrentType}
+ newType={props.NewType}
+ teamName={props.TeamName}
+ teamDisplayName={props.TeamDisplayName}
+ />,
+ document.getElementById('claim')
+ );
+}
+
+global.window.setup_claim_account_page = setupClaimAccountPage;
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index 8a4cee589..e1c331aff 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -228,6 +228,40 @@ export function resetPassword(data, success, error) {
track('api', 'api_users_reset_password');
}
+export function switchToSSO(data, success, error) {
+ $.ajax({
+ url: '/api/v1/users/switch_to_sso',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('switchToSSO', xhr, status, err);
+ error(e);
+ }
+ });
+
+ track('api', 'api_users_switch_to_sso');
+}
+
+export function switchToEmail(data, success, error) {
+ $.ajax({
+ url: '/api/v1/users/switch_to_email',
+ dataType: 'json',
+ contentType: 'application/json',
+ type: 'POST',
+ data: JSON.stringify(data),
+ success,
+ error: function onError(xhr, status, err) {
+ var e = handleError('switchToEmail', xhr, status, err);
+ error(e);
+ }
+ });
+
+ track('api', 'api_users_switch_to_email');
+}
+
export function logout() {
track('api', 'api_users_logout');
var currentTeamUrl = TeamStore.getCurrentTeamUrl();
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index ea4921417..0298ce533 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -117,6 +117,8 @@ export default {
GITLAB_SERVICE: 'gitlab',
GOOGLE_SERVICE: 'google',
EMAIL_SERVICE: 'email',
+ SIGNIN_CHANGE: 'signin_change',
+ SIGNIN_VERIFIED: 'verified',
POST_CHUNK_SIZE: 60,
MAX_POST_CHUNKS: 3,
POST_FOCUS_CONTEXT_RADIUS: 10,
diff --git a/web/templates/claim_account.html b/web/templates/claim_account.html
new file mode 100644
index 000000000..6c9f36fa7
--- /dev/null
+++ b/web/templates/claim_account.html
@@ -0,0 +1,16 @@
+{{define "claim_account"}}
+<!DOCTYPE html>
+<html>
+{{template "head" . }}
+<body class="white">
+ <div class="container-fluid">
+ <div class="inner__wrap">
+ <div class="row content" id="claim"></div>
+ </div>
+ </div>
+ <script>
+ window.setup_claim_account_page({{ .Props }});
+ </script>
+</body>
+</html>
+{{end}}
diff --git a/web/web.go b/web/web.go
index f1e8471b8..6e0e8df32 100644
--- a/web/web.go
+++ b/web/web.go
@@ -8,7 +8,6 @@ import (
"fmt"
"github.com/gorilla/mux"
"github.com/mattermost/platform/api"
- "github.com/mattermost/platform/einterfaces"
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
@@ -71,8 +70,7 @@ func InitWeb() {
mainrouter.Handle("/verify_email", api.AppHandlerIndependent(verifyEmail)).Methods("GET")
mainrouter.Handle("/find_team", api.AppHandlerIndependent(findTeam)).Methods("GET")
mainrouter.Handle("/signup_team", api.AppHandlerIndependent(signup)).Methods("GET")
- mainrouter.Handle("/login/{service:[A-Za-z]+}/complete", api.AppHandlerIndependent(loginCompleteOAuth)).Methods("GET")
- mainrouter.Handle("/signup/{service:[A-Za-z]+}/complete", api.AppHandlerIndependent(signupCompleteOAuth)).Methods("GET")
+ mainrouter.Handle("/{service:[A-Za-z]+}/complete", api.AppHandlerIndependent(completeOAuth)).Methods("GET")
mainrouter.Handle("/admin_console", api.UserRequired(adminConsole)).Methods("GET")
mainrouter.Handle("/admin_console/", api.UserRequired(adminConsole)).Methods("GET")
@@ -92,6 +90,7 @@ func InitWeb() {
mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/login", api.AppHandler(login)).Methods("GET")
mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/logout", api.AppHandler(logout)).Methods("GET")
mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/reset_password", api.AppHandler(resetPassword)).Methods("GET")
+ mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}/claim", api.AppHandler(claimAccount)).Methods("GET")
mainrouter.Handle("/{team}/pl/{postid}", api.AppHandler(postPermalink)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here.
mainrouter.Handle("/{team}/login/{service}", api.AppHandler(loginWithOAuth)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here.
mainrouter.Handle("/{team}/channels/{channelname}", api.AppHandler(getChannel)).Methods("GET") // Bug in gorilla.mux prevents us from using regex here.
@@ -565,7 +564,7 @@ func verifyEmail(c *api.Context, w http.ResponseWriter, r *http.Request) {
return
} else {
c.LogAudit("Email Verified")
- http.Redirect(w, r, api.GetProtocol(r)+"://"+r.Host+"/"+name+"/login?verified=true&email="+url.QueryEscape(email), http.StatusTemporaryRedirect)
+ http.Redirect(w, r, api.GetProtocol(r)+"://"+r.Host+"/"+name+"/login?extra=verified&email="+url.QueryEscape(email), http.StatusTemporaryRedirect)
return
}
}
@@ -687,89 +686,63 @@ func signupWithOAuth(c *api.Context, w http.ResponseWriter, r *http.Request) {
}
}
- redirectUri := c.GetSiteURL() + "/signup/" + service + "/complete"
+ stateProps := map[string]string{}
+ stateProps["action"] = model.OAUTH_ACTION_SIGNUP
- api.GetAuthorizationCode(c, w, r, teamName, service, redirectUri, "")
+ if authUrl, err := api.GetAuthorizationCode(c, service, teamName, stateProps, ""); err != nil {
+ c.Err = err
+ return
+ } else {
+ http.Redirect(w, r, authUrl, http.StatusFound)
+ }
}
-func signupCompleteOAuth(c *api.Context, w http.ResponseWriter, r *http.Request) {
+func completeOAuth(c *api.Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
service := params["service"]
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
- uri := c.GetSiteURL() + "/signup/" + service + "/complete"
+ uri := c.GetSiteURL() + "/" + service + "/complete"
- if body, team, err := api.AuthorizeOAuthUser(service, code, state, uri); err != nil {
+ if body, team, props, err := api.AuthorizeOAuthUser(service, code, state, uri); err != nil {
c.Err = err
return
} else {
- var user *model.User
- provider := einterfaces.GetOauthProvider(service)
- if provider == nil {
- c.Err = model.NewAppError("signupCompleteOAuth", service+" oauth not avlailable on this server", "")
- return
- } else {
- user = provider.GetUserFromJson(body)
- }
-
- if user == nil {
- c.Err = model.NewAppError("signupCompleteOAuth", "Could not create user out of "+service+" user object", "")
- return
- }
-
- suchan := api.Srv.Store.User().GetByAuth(team.Id, user.AuthData, service)
- euchan := api.Srv.Store.User().GetByEmail(team.Id, user.Email)
-
- if team.Email == "" {
- team.Email = user.Email
- if result := <-api.Srv.Store.Team().Update(team); result.Err != nil {
- c.Err = result.Err
- return
+ action := props["action"]
+ switch action {
+ case model.OAUTH_ACTION_SIGNUP:
+ api.CreateOAuthUser(c, w, r, service, body, team)
+ if c.Err == nil {
+ root(c, w, r)
}
- } else {
- found := true
- count := 0
- for found {
- if found = api.IsUsernameTaken(user.Username, team.Id); c.Err != nil {
- return
- } else if found {
- user.Username = user.Username + strconv.Itoa(count)
- count += 1
- }
+ break
+ case model.OAUTH_ACTION_LOGIN:
+ api.LoginByOAuth(c, w, r, service, body, team)
+ if c.Err == nil {
+ root(c, w, r)
}
+ break
+ case model.OAUTH_ACTION_EMAIL_TO_SSO:
+ api.CompleteSwitchWithOAuth(c, w, r, service, body, team, props["email"])
+ if c.Err == nil {
+ http.Redirect(w, r, api.GetProtocol(r)+"://"+r.Host+"/"+team.Name+"/login?extra=signin_change", http.StatusTemporaryRedirect)
+ }
+ break
+ case model.OAUTH_ACTION_SSO_TO_EMAIL:
+ api.LoginByOAuth(c, w, r, service, body, team)
+ if c.Err == nil {
+ http.Redirect(w, r, api.GetProtocol(r)+"://"+r.Host+"/"+team.Name+"/"+"/claim?email="+url.QueryEscape(props["email"]), http.StatusTemporaryRedirect)
+ }
+ break
+ default:
+ api.LoginByOAuth(c, w, r, service, body, team)
+ if c.Err == nil {
+ root(c, w, r)
+ }
+ break
}
-
- if result := <-suchan; result.Err == nil {
- c.Err = model.NewAppError("signupCompleteOAuth", "This "+service+" account has already been used to sign up for team "+team.DisplayName, "email="+user.Email)
- return
- }
-
- if result := <-euchan; result.Err == nil {
- c.Err = model.NewAppError("signupCompleteOAuth", "Team "+team.DisplayName+" already has a user with the email address attached to your "+service+" account", "email="+user.Email)
- return
- }
-
- user.TeamId = team.Id
- user.EmailVerified = true
-
- ruser, err := api.CreateUser(team, user)
- if err != nil {
- c.Err = err
- return
- }
-
- api.Login(c, w, r, ruser, "")
-
- if c.Err != nil {
- return
- }
-
- page := NewHtmlTemplatePage("home", "Home")
- page.Team = team
- page.User = ruser
- page.Render(c, w)
}
}
@@ -791,57 +764,14 @@ func loginWithOAuth(c *api.Context, w http.ResponseWriter, r *http.Request) {
return
}
- redirectUri := c.GetSiteURL() + "/login/" + service + "/complete"
-
- api.GetAuthorizationCode(c, w, r, teamName, service, redirectUri, loginHint)
-}
-
-func loginCompleteOAuth(c *api.Context, w http.ResponseWriter, r *http.Request) {
- params := mux.Vars(r)
- service := params["service"]
-
- code := r.URL.Query().Get("code")
- state := r.URL.Query().Get("state")
-
- uri := c.GetSiteURL() + "/login/" + service + "/complete"
+ stateProps := map[string]string{}
+ stateProps["action"] = model.OAUTH_ACTION_LOGIN
- if body, team, err := api.AuthorizeOAuthUser(service, code, state, uri); err != nil {
+ if authUrl, err := api.GetAuthorizationCode(c, service, teamName, stateProps, loginHint); err != nil {
c.Err = err
return
} else {
- authData := ""
- provider := einterfaces.GetOauthProvider(service)
- if provider == nil {
- c.Err = model.NewAppError("signupCompleteOAuth", service+" oauth not avlailable on this server", "")
- return
- } else {
- authData = provider.GetAuthDataFromJson(body)
- }
-
- if len(authData) == 0 {
- c.Err = model.NewAppError("loginCompleteOAuth", "Could not parse auth data out of "+service+" user object", "")
- return
- }
-
- var user *model.User
- if result := <-api.Srv.Store.User().GetByAuth(team.Id, authData, service); result.Err != nil {
- c.Err = result.Err
- return
- } else {
- user = result.Data.(*model.User)
- api.Login(c, w, r, user, "")
-
- if c.Err != nil {
- return
- }
-
- page := NewHtmlTemplatePage("home", "Home")
- page.Team = team
- page.User = user
- page.Render(c, w)
-
- root(c, w, r)
- }
+ http.Redirect(w, r, authUrl, http.StatusFound)
}
}
@@ -1172,3 +1102,58 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
+
+func claimAccount(c *api.Context, w http.ResponseWriter, r *http.Request) {
+ if !CheckBrowserCompatability(c, r) {
+ return
+ }
+
+ params := mux.Vars(r)
+ teamName := params["team"]
+ email := r.URL.Query().Get("email")
+ newType := r.URL.Query().Get("new_type")
+
+ var team *model.Team
+ if tResult := <-api.Srv.Store.Team().GetByName(teamName); tResult.Err != nil {
+ l4g.Error("Couldn't find team name=%v, err=%v", teamName, tResult.Err.Message)
+ http.Redirect(w, r, api.GetProtocol(r)+"://"+r.Host, http.StatusTemporaryRedirect)
+ return
+ } else {
+ team = tResult.Data.(*model.Team)
+ }
+
+ authType := ""
+ if len(email) != 0 {
+ if uResult := <-api.Srv.Store.User().GetByEmail(team.Id, email); uResult.Err != nil {
+ l4g.Error("Couldn't find user teamid=%v, email=%v, err=%v", team.Id, email, uResult.Err.Message)
+ http.Redirect(w, r, api.GetProtocol(r)+"://"+r.Host, http.StatusTemporaryRedirect)
+ return
+ } else {
+ user := uResult.Data.(*model.User)
+ authType = user.AuthService
+
+ // if user is not logged in to their SSO account, ask them to log in
+ if len(authType) != 0 && user.Id != c.Session.UserId {
+ stateProps := map[string]string{}
+ stateProps["action"] = model.OAUTH_ACTION_SSO_TO_EMAIL
+ stateProps["email"] = email
+
+ if authUrl, err := api.GetAuthorizationCode(c, authType, team.Name, stateProps, ""); err != nil {
+ c.Err = err
+ return
+ } else {
+ http.Redirect(w, r, authUrl, http.StatusFound)
+ }
+ }
+ }
+ }
+
+ page := NewHtmlTemplatePage("claim_account", "Claim Account")
+ page.Props["Email"] = email
+ page.Props["CurrentType"] = authType
+ page.Props["NewType"] = newType
+ page.Props["TeamDisplayName"] = team.DisplayName
+ page.Props["TeamName"] = team.Name
+
+ page.Render(c, w)
+}