diff options
Diffstat (limited to 'api')
-rw-r--r-- | api/api.go | 2 | ||||
-rw-r--r-- | api/context.go | 36 | ||||
-rw-r--r-- | api/file.go | 5 | ||||
-rw-r--r-- | api/oauth.go | 7 | ||||
-rw-r--r-- | api/post.go | 2 | ||||
-rw-r--r-- | api/user.go | 199 | ||||
-rw-r--r-- | api/user_test.go | 76 |
7 files changed, 293 insertions, 34 deletions
diff --git a/api/api.go b/api/api.go index 20f77e558..476047877 100644 --- a/api/api.go +++ b/api/api.go @@ -27,6 +27,8 @@ func InitApi() { InitWebhook(r) InitPreference(r) InitLicense(r) + // 404 on any api route before web.go has a chance to serve it + Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404)) utils.InitHTML() } diff --git a/api/context.go b/api/context.go index eed035daf..0f7ba0fff 100644 --- a/api/context.go +++ b/api/context.go @@ -476,25 +476,23 @@ func IsPrivateIpAddress(ipAddress string) bool { } func RenderWebError(err *model.AppError, w http.ResponseWriter, r *http.Request) { - T, locale := utils.GetTranslationsAndLocale(w, r) - page := utils.NewHTMLTemplate("error", locale) - page.Props["Message"] = err.Message - page.Props["Details"] = err.DetailedError - - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) > 1 { - page.Props["SiteURL"] = GetProtocol(r) + "://" + r.Host + "/" + pathParts[1] - } else { - page.Props["SiteURL"] = GetProtocol(r) + "://" + r.Host - } - - page.Props["Title"] = T("api.templates.error.title", map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]}) - page.Props["Link"] = T("api.templates.error.link") - - w.WriteHeader(err.StatusCode) - if rErr := page.RenderToWriter(w); rErr != nil { - l4g.Error("Failed to create error page: " + rErr.Error() + ", Original error: " + err.Error()) - } + T, _ := utils.GetTranslationsAndLocale(w, r) + + title := T("api.templates.error.title", map[string]interface{}{"SiteName": utils.ClientCfg["SiteName"]}) + message := err.Message + details := err.DetailedError + link := "/" + linkMessage := T("api.templates.error.link") + + http.Redirect( + w, + r, + "/error?title="+url.QueryEscape(title)+ + "&message="+url.QueryEscape(message)+ + "&details="+url.QueryEscape(details)+ + "&link="+url.QueryEscape(link)+ + "&linkmessage="+url.QueryEscape(linkMessage), + http.StatusTemporaryRedirect) } func Handle404(w http.ResponseWriter, r *http.Request) { diff --git a/api/file.go b/api/file.go index 9150e4bfe..f0873f884 100644 --- a/api/file.go +++ b/api/file.go @@ -394,6 +394,11 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { getFileAndForget(path, fileData) if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 { + if !utils.Cfg.FileSettings.EnablePublicLink { + c.Err = model.NewLocAppError("getFile", "api.file.get_file.public_disabled.app_error", nil, "") + return + } + if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.FileSettings.PublicLinkSalt)) { c.Err = model.NewLocAppError("getFile", "api.file.get_file.public_invalid.app_error", nil, "") return diff --git a/api/oauth.go b/api/oauth.go index 9b7f3699d..a7119d7e5 100644 --- a/api/oauth.go +++ b/api/oauth.go @@ -29,11 +29,14 @@ func InitOAuth(r *mux.Router) { sr.Handle("/authorize", ApiUserRequired(authorizeOAuth)).Methods("GET") sr.Handle("/access_token", ApiAppHandler(getAccessToken)).Methods("POST") - // Also handle this a the old routes remove soon apiv2? mr := Srv.Router mr.Handle("/authorize", ApiUserRequired(authorizeOAuth)).Methods("GET") mr.Handle("/access_token", ApiAppHandler(getAccessToken)).Methods("POST") + + // Handle all the old routes, to be later removed mr.Handle("/{service:[A-Za-z]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET") + mr.Handle("/signup/{service:[A-Za-z]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET") + mr.Handle("/login/{service:[A-Za-z]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET") } func registerOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { @@ -185,7 +188,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") - uri := c.GetSiteURL() + "/api/v1/oauth/" + service + "/complete" + uri := c.GetSiteURL() + "/signup/" + service + "/complete" if body, team, props, err := AuthorizeOAuthUser(service, code, state, uri); err != nil { c.Err = err diff --git a/api/post.go b/api/post.go index 36fd4ee79..2fe5feb8e 100644 --- a/api/post.go +++ b/api/post.go @@ -172,8 +172,6 @@ func CreateWebhookPost(c *Context, channelId, text, overrideUsername, overrideIc if utils.Cfg.ServiceSettings.EnablePostIconOverride { if len(overrideIconUrl) != 0 { post.AddProp("override_icon_url", overrideIconUrl) - } else { - post.AddProp("override_icon_url", model.DEFAULT_WEBHOOK_ICON) } } diff --git a/api/user.go b/api/user.go index 6803a946c..60b92f90d 100644 --- a/api/user.go +++ b/api/user.go @@ -53,6 +53,9 @@ func InitUser(r *mux.Router) { sr.Handle("/attach_device", ApiUserRequired(attachDeviceId)).Methods("POST") sr.Handle("/verify_email", ApiAppHandler(verifyEmail)).Methods("POST") sr.Handle("/resend_verification", ApiAppHandler(resendVerification)).Methods("POST") + sr.Handle("/mfa", ApiAppHandler(checkMfa)).Methods("POST") + sr.Handle("/generate_mfa_qr", ApiUserRequired(generateMfaQrCode)).Methods("GET") + sr.Handle("/update_mfa", ApiUserRequired(updateMfa)).Methods("POST") sr.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST") @@ -405,13 +408,14 @@ func SendVerifyEmailAndForget(c *Context, userId, userEmail, teamName, teamDispl }() } -func LoginById(c *Context, w http.ResponseWriter, r *http.Request, userId, password, deviceId string) *model.User { +func LoginById(c *Context, w http.ResponseWriter, r *http.Request, userId, password, mfaToken, deviceId string) *model.User { if result := <-Srv.Store.User().Get(userId); result.Err != nil { c.Err = result.Err return nil } else { user := result.Data.(*model.User) - if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) { + + if authenticateUserPasswordAndToken(c, user, password, mfaToken) { Login(c, w, r, user, deviceId) return user } @@ -420,7 +424,7 @@ func LoginById(c *Context, w http.ResponseWriter, r *http.Request, userId, passw return nil } -func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, name, password, deviceId string) *model.User { +func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, name, password, mfaToken, deviceId string) *model.User { var team *model.Team if result := <-Srv.Store.Team().GetByName(name); result.Err != nil { @@ -443,7 +447,7 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam return nil } - if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) { + if authenticateUserPasswordAndToken(c, user, password, mfaToken) { Login(c, w, r, user, deviceId) return user } @@ -452,7 +456,7 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam return nil } -func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, username, name, password, deviceId string) *model.User { +func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, username, name, password, mfaToken, deviceId string) *model.User { var team *model.Team if result := <-Srv.Store.Team().GetByName(name); result.Err != nil { @@ -475,7 +479,7 @@ func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, usernam return nil } - if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) { + if authenticateUserPasswordAndToken(c, user, password, mfaToken) { Login(c, w, r, user, deviceId) return user } @@ -518,6 +522,10 @@ func LoginByOAuth(c *Context, w http.ResponseWriter, r *http.Request, service st } } +func authenticateUserPasswordAndToken(c *Context, user *model.User, password string, token string) bool { + return checkUserLoginAttempts(c, user) && checkUserMfa(c, user, token) && checkUserPassword(c, user, password) +} + func checkUserLoginAttempts(c *Context, user *model.User) bool { if user.FailedAttempts >= utils.Cfg.ServiceSettings.MaximumLoginAttempts { c.LogAuditWithUserId(user.Id, "fail") @@ -530,7 +538,6 @@ func checkUserLoginAttempts(c *Context, user *model.User) bool { } func checkUserPassword(c *Context, user *model.User, password string) bool { - if !model.ComparePassword(user.Password, password) { c.LogAuditWithUserId(user.Id, "fail") c.Err = model.NewLocAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id) @@ -548,7 +555,29 @@ func checkUserPassword(c *Context, user *model.User, password string) bool { return true } +} + +func checkUserMfa(c *Context, user *model.User, token string) bool { + if !user.MfaActive || !utils.IsLicensed || !*utils.License.Features.MFA || !*utils.Cfg.ServiceSettings.EnableMultifactorAuthentication { + return true + } + mfaInterface := einterfaces.GetMfaInterface() + if mfaInterface == nil { + c.Err = model.NewLocAppError("checkUserMfa", "api.user.check_user_mfa.not_available.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return false + } + + if ok, err := mfaInterface.ValidateToken(user.MfaSecret, token); err != nil { + c.Err = err + return false + } else if !ok { + c.Err = model.NewLocAppError("checkUserMfa", "api.user.check_user_mfa.bad_code.app_error", nil, "") + return false + } else { + return true + } } // User MUST be validated before calling Login @@ -660,11 +689,11 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { var user *model.User if len(props["id"]) != 0 { - user = LoginById(c, w, r, props["id"], props["password"], props["device_id"]) + user = LoginById(c, w, r, props["id"], props["password"], props["token"], props["device_id"]) } else if len(props["email"]) != 0 && len(props["name"]) != 0 { - user = LoginByEmail(c, w, r, props["email"], props["name"], props["password"], props["device_id"]) + user = LoginByEmail(c, w, r, props["email"], props["name"], props["password"], props["token"], props["device_id"]) } else if len(props["username"]) != 0 && len(props["name"]) != 0 { - user = LoginByUsername(c, w, r, props["username"], props["name"], props["password"], props["device_id"]) + user = LoginByUsername(c, w, r, props["username"], props["name"], props["password"], props["token"], props["device_id"]) } else { c.Err = model.NewLocAppError("login", "api.user.login.not_provided.app_error", nil, "") c.Err.StatusCode = http.StatusForbidden @@ -695,6 +724,7 @@ func loginLdap(c *Context, w http.ResponseWriter, r *http.Request) { password := props["password"] id := props["id"] teamName := props["teamName"] + mfaToken := props["token"] if len(password) == 0 { c.Err = model.NewLocAppError("loginLdap", "api.user.login_ldap.blank_pwd.app_error", nil, "") @@ -735,6 +765,10 @@ func loginLdap(c *Context, w http.ResponseWriter, r *http.Request) { return } + if !checkUserMfa(c, user, mfaToken) { + return + } + // User is authenticated at this point Login(c, w, r, user, props["device_id"]) @@ -1938,7 +1972,7 @@ func GetAuthorizationCode(c *Context, service, teamName string, props map[string props["team"] = teamName state := b64.StdEncoding.EncodeToString([]byte(model.MapToJson(props))) - redirectUri := c.GetSiteURL() + "/api/v1/oauth/" + service + "/complete" + redirectUri := c.GetSiteURL() + "/signup/" + service + "/complete" authUrl := endpoint + "?response_type=code&client_id=" + clientId + "&redirect_uri=" + url.QueryEscape(redirectUri) + "&state=" + url.QueryEscape(state) @@ -2487,3 +2521,146 @@ func resendVerification(c *Context, w http.ResponseWriter, r *http.Request) { } } } + +func generateMfaQrCode(c *Context, w http.ResponseWriter, r *http.Request) { + uchan := Srv.Store.User().Get(c.Session.UserId) + tchan := Srv.Store.Team().Get(c.Session.TeamId) + + var user *model.User + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + user = result.Data.(*model.User) + } + + var team *model.Team + if result := <-tchan; result.Err != nil { + c.Err = result.Err + return + } else { + team = result.Data.(*model.Team) + } + + mfaInterface := einterfaces.GetMfaInterface() + if mfaInterface == nil { + c.Err = model.NewLocAppError("generateMfaQrCode", "api.user.generate_mfa_qr.not_available.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + img, err := mfaInterface.GenerateQrCode(team, user) + if err != nil { + c.Err = err + return + } + + w.Header().Del("Content-Type") // Content-Type will be set automatically by the http writer + w.Write(img) +} + +func updateMfa(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.StringInterfaceFromJson(r.Body) + + activate, ok := props["activate"].(bool) + if !ok { + c.SetInvalidParam("updateMfa", "activate") + return + } + + token := "" + if activate { + token = props["token"].(string) + if len(token) == 0 { + c.SetInvalidParam("updateMfa", "token") + return + } + } + + mfaInterface := einterfaces.GetMfaInterface() + if mfaInterface == nil { + c.Err = model.NewLocAppError("generateMfaQrCode", "api.user.update_mfa.not_available.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if activate { + var user *model.User + if result := <-Srv.Store.User().Get(c.Session.UserId); result.Err != nil { + c.Err = result.Err + return + } else { + user = result.Data.(*model.User) + } + + if err := mfaInterface.Activate(user, token); err != nil { + c.Err = err + return + } + } else { + if err := mfaInterface.Deactivate(c.Session.UserId); err != nil { + c.Err = err + return + } + } + + rdata := map[string]string{} + rdata["status"] = "ok" + w.Write([]byte(model.MapToJson(rdata))) +} + +func checkMfa(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.IsLicensed || !*utils.License.Features.MFA || !*utils.Cfg.ServiceSettings.EnableMultifactorAuthentication { + rdata := map[string]string{} + rdata["mfa_required"] = "false" + w.Write([]byte(model.MapToJson(rdata))) + return + } + + props := model.MapFromJson(r.Body) + + method := props["method"] + if method != model.USER_AUTH_SERVICE_EMAIL && + method != model.USER_AUTH_SERVICE_USERNAME && + method != model.USER_AUTH_SERVICE_LDAP { + c.SetInvalidParam("checkMfa", "method") + return + } + + teamName := props["team_name"] + if len(teamName) == 0 { + c.SetInvalidParam("checkMfa", "team_name") + return + } + + loginId := props["login_id"] + if len(loginId) == 0 { + c.SetInvalidParam("checkMfa", "login_id") + return + } + + var team *model.Team + if result := <-Srv.Store.Team().GetByName(teamName); result.Err != nil { + c.Err = result.Err + return + } else { + team = result.Data.(*model.Team) + } + + var uchan store.StoreChannel + if method == model.USER_AUTH_SERVICE_EMAIL { + uchan = Srv.Store.User().GetByEmail(team.Id, loginId) + } else if method == model.USER_AUTH_SERVICE_USERNAME { + uchan = Srv.Store.User().GetByUsername(team.Id, loginId) + } else if method == model.USER_AUTH_SERVICE_LDAP { + uchan = Srv.Store.User().GetByAuth(team.Id, loginId, model.USER_AUTH_SERVICE_LDAP) + } + + rdata := map[string]string{} + if result := <-uchan; result.Err != nil { + rdata["mfa_required"] = "false" + } else { + rdata["mfa_required"] = strconv.FormatBool(result.Data.(*model.User).MfaActive) + } + w.Write([]byte(model.MapToJson(rdata))) +} diff --git a/api/user_test.go b/api/user_test.go index 86cda0390..33f3fdad4 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -1411,3 +1411,79 @@ func TestMeLoggedIn(t *testing.T) { } } } + +func TestGenerateMfaQrCode(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()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"} + ruser, _ := Client.CreateUser(&user, "") + store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)) + + Client.Logout() + + if _, err := Client.GenerateMfaQrCode(); err == nil { + t.Fatal("should have failed - not logged in") + } + + Client.LoginByEmail(team.Name, user.Email, user.Password) + + if _, err := Client.GenerateMfaQrCode(); err == nil { + t.Fatal("should have failed - not licensed") + } + + // need to add more test cases when license and config can be configured for tests +} + +func TestUpdateMfa(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()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"} + ruser, _ := Client.CreateUser(&user, "") + store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)) + + Client.Logout() + + if _, err := Client.UpdateMfa(true, "123456"); err == nil { + t.Fatal("should have failed - not logged in") + } + + Client.LoginByEmail(team.Name, user.Email, user.Password) + + if _, err := Client.UpdateMfa(true, ""); err == nil { + t.Fatal("should have failed - no token") + } + + if _, err := Client.UpdateMfa(true, "123456"); err == nil { + t.Fatal("should have failed - not licensed") + } + + // need to add more test cases when license and config can be configured for tests +} + +func TestCheckMfa(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()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"} + ruser, _ := Client.CreateUser(&user, "") + store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)) + + if result, err := Client.CheckMfa(model.USER_AUTH_SERVICE_EMAIL, team.Name, user.Email); err != nil { + t.Fatal(err) + } else { + resp := result.Data.(map[string]string) + if resp["mfa_required"] != "false" { + t.Fatal("mfa should not be required") + } + } + + // need to add more test cases when license and config can be configured for tests +} |