[MM-28083] CWS one-time login logic (#15356)
* Cloud token login This PR adds the capability of activate the cloud token login that will be used in our Cloud installations to let the customer login for the first time without using credentials. * Read CSRF from cookie when is not on the header and we're login with CWS * Create new CWS login endpoint - New endpoint created - We're using the cloud feature from the license instead of the configuration flag - Removed the CSRF changes * Reduce amount of work if cws token is not set * Removed unused config key * Now we store the token to detect it was used If the token is in the token store then we are assuming that the token was used * Add tests * Add i18n strings
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
26cdbd5dba
Коммит
22297a9bf4
46
api4/user.go
46
api4/user.go
@@ -63,6 +63,7 @@ func (api *API) InitUser() {
|
||||
|
||||
api.BaseRoutes.Users.Handle("/login", api.ApiHandler(login)).Methods("POST")
|
||||
api.BaseRoutes.Users.Handle("/login/switch", api.ApiHandler(switchAccountType)).Methods("POST")
|
||||
api.BaseRoutes.Users.Handle("/login/cws", api.ApiHandlerTrustRequester(loginCWS)).Methods("POST")
|
||||
api.BaseRoutes.Users.Handle("/logout", api.ApiHandler(logout)).Methods("POST")
|
||||
|
||||
api.BaseRoutes.UserByUsername.Handle("", api.ApiSessionRequired(getUserByUsername)).Methods("GET")
|
||||
@@ -1652,7 +1653,6 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
|
||||
props := model.MapFromJson(r.Body)
|
||||
|
||||
id := props["id"]
|
||||
loginId := props["login_id"]
|
||||
password := props["password"]
|
||||
@@ -1686,7 +1686,7 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
c.LogAuditWithUserId(id, "attempt - login_id="+loginId)
|
||||
|
||||
user, err := c.App.AuthenticateUserForLogin(id, loginId, password, mfaToken, ldapOnly)
|
||||
user, err := c.App.AuthenticateUserForLogin(id, loginId, password, mfaToken, "", ldapOnly)
|
||||
if err != nil {
|
||||
c.LogAuditWithUserId(id, "failure - login_id="+loginId)
|
||||
c.Err = err
|
||||
@@ -1736,6 +1736,48 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(user.ToJson()))
|
||||
}
|
||||
|
||||
func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud {
|
||||
c.Err = model.NewAppError("loginCWS", "api.user.login_cws.license.error", nil, "", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
r.ParseForm()
|
||||
var loginID string
|
||||
var token string
|
||||
if len(r.Form) > 0 {
|
||||
for key, value := range r.Form {
|
||||
if key == "login_id" {
|
||||
loginID = value[0]
|
||||
}
|
||||
if key == "cws_token" {
|
||||
token = value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("login", audit.Fail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
auditRec.AddMeta("login_id", loginID)
|
||||
user, err := c.App.AuthenticateUserForLogin("", loginID, "", "", token, false)
|
||||
if err != nil {
|
||||
c.LogAuditWithUserId("", "failure - login_id="+loginID)
|
||||
mlog.Error("CWS authentication error", mlog.Err(err))
|
||||
http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, 302)
|
||||
return
|
||||
}
|
||||
auditRec.AddMeta("user", user)
|
||||
c.LogAuditWithUserId(user.Id, "authenticated")
|
||||
err = c.App.DoLogin(w, r, user, "", false, false, false)
|
||||
if err != nil {
|
||||
mlog.Error("CWS login error", mlog.Err(err))
|
||||
http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, 302)
|
||||
return
|
||||
}
|
||||
c.LogAuditWithUserId(user.Id, "success")
|
||||
c.App.AttachSessionCookies(w, r)
|
||||
http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, 302)
|
||||
}
|
||||
|
||||
func logout(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
Logout(c, w, r)
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ type AppIface interface {
|
||||
AsymmetricSigningKey() *ecdsa.PrivateKey
|
||||
AttachDeviceId(sessionId string, deviceId string, expiresAt int64) *model.AppError
|
||||
AttachSessionCookies(w http.ResponseWriter, r *http.Request)
|
||||
AuthenticateUserForLogin(id, loginId, password, mfaToken string, ldapOnly bool) (user *model.User, err *model.AppError)
|
||||
AuthenticateUserForLogin(id, loginId, password, mfaToken, cwsToken string, ldapOnly bool) (user *model.User, err *model.AppError)
|
||||
AuthorizeOAuthUser(w http.ResponseWriter, r *http.Request, service, code, state, redirectUri string) (io.ReadCloser, string, map[string]string, *model.AppError)
|
||||
AutocompleteChannels(teamId string, term string) (*model.ChannelList, *model.AppError)
|
||||
AutocompleteChannelsForSearch(teamId string, userId string, term string) (*model.ChannelList, *model.AppError)
|
||||
|
||||
50
app/login.go
50
app/login.go
@@ -4,19 +4,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/avct/uasurfer"
|
||||
"github.com/mattermost/mattermost-server/v5/mlog"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||
"github.com/mattermost/mattermost-server/v5/store"
|
||||
"github.com/mattermost/mattermost-server/v5/utils"
|
||||
)
|
||||
|
||||
const cwsTokenEnv = "CWS_CLOUD_TOKEN"
|
||||
|
||||
func (a *App) CheckForClientSideCert(r *http.Request) (string, string, string) {
|
||||
pem := r.Header.Get("X-SSL-Client-Cert") // mapped to $ssl_client_cert from nginx
|
||||
subject := r.Header.Get("X-SSL-Client-Cert-Subject-DN") // mapped to $ssl_client_s_dn from nginx
|
||||
@@ -34,7 +40,7 @@ func (a *App) CheckForClientSideCert(r *http.Request) (string, string, string) {
|
||||
return pem, subject, email
|
||||
}
|
||||
|
||||
func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken string, ldapOnly bool) (user *model.User, err *model.AppError) {
|
||||
func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken, cwsToken string, ldapOnly bool) (user *model.User, err *model.AppError) {
|
||||
// Do statistics
|
||||
defer func() {
|
||||
if a.Metrics() != nil {
|
||||
@@ -46,7 +52,7 @@ func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken string, l
|
||||
}
|
||||
}()
|
||||
|
||||
if len(password) == 0 {
|
||||
if len(password) == 0 && !IsCWSLogin(a, cwsToken) {
|
||||
return nil, model.NewAppError("AuthenticateUserForLogin", "api.user.login.blank_pwd.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -55,6 +61,42 @@ func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken string, l
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// CWS login allow to use the one-time token to login the users when they're redirected to their
|
||||
// installation for the first time
|
||||
if IsCWSLogin(a, cwsToken) {
|
||||
if err = checkUserNotBot(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := a.Srv().Store.Token().GetByToken(cwsToken)
|
||||
if nfErr := new(store.ErrNotFound); err != nil && !errors.As(err, &nfErr) {
|
||||
mlog.Error("error retrieving the cws token from the store", mlog.Err(err))
|
||||
return nil, model.NewAppError("AuthenticateUserForLogin",
|
||||
"api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusInternalServerError)
|
||||
}
|
||||
// If token is stored in the database that means it was used
|
||||
if token != nil {
|
||||
return nil, model.NewAppError("AuthenticateUserForLogin",
|
||||
"api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
envToken, ok := os.LookupEnv(cwsTokenEnv)
|
||||
if ok && subtle.ConstantTimeCompare([]byte(envToken), []byte(cwsToken)) == 1 {
|
||||
token = &model.Token{
|
||||
Token: cwsToken,
|
||||
CreateAt: model.GetMillis(),
|
||||
Type: TOKEN_TYPE_CWS_ACCESS,
|
||||
}
|
||||
err := a.Srv().Store.Token().Save(token)
|
||||
if err != nil {
|
||||
mlog.Error("error storing the cws token in the store", mlog.Err(err))
|
||||
return nil, model.NewAppError("AuthenticateUserForLogin",
|
||||
"api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusInternalServerError)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
return nil, model.NewAppError("AuthenticateUserForLogin",
|
||||
"api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// If client side cert is enable and it's checking as a primary source
|
||||
// then trust the proxy and cert that the correct user is supplied and allow
|
||||
// them access
|
||||
@@ -246,3 +288,7 @@ func GetProtocol(r *http.Request) string {
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func IsCWSLogin(a *App, token string) bool {
|
||||
return a.Srv().License() != nil && *a.Srv().License().Features.Cloud && token != ""
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -35,3 +37,35 @@ func TestCheckForClientSideCert(t *testing.T) {
|
||||
require.Equal(t, actualEmail, tt.expectedEmail, "CheckForClientSideCert(%v): expected %v, actual %v", tt.subject, tt.expectedEmail, actualEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCWSLogin(t *testing.T) {
|
||||
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
license := model.NewTestLicense()
|
||||
license.Features.Cloud = model.NewBool(true)
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
t.Run("Should authenticate user when CWS login is enabled and tokens are equal", func(t *testing.T) {
|
||||
token := model.NewToken(TOKEN_TYPE_CWS_ACCESS, "")
|
||||
defer th.App.DeleteToken(token)
|
||||
os.Setenv("CWS_CLOUD_TOKEN", token.Token)
|
||||
user, err := th.App.AuthenticateUserForLogin("", th.BasicUser.Username, "", "", token.Token, false)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, user)
|
||||
require.Equal(t, th.BasicUser.Username, user.Username)
|
||||
_, apperr := th.App.Srv().Store.Token().GetByToken(token.Token)
|
||||
require.Nil(t, apperr)
|
||||
th.App.DeleteToken(token)
|
||||
})
|
||||
|
||||
t.Run("Should not authenticate the user when CWS token was used", func(t *testing.T) {
|
||||
token := model.NewToken(TOKEN_TYPE_CWS_ACCESS, "")
|
||||
os.Setenv("CWS_CLOUD_TOKEN", token.Token)
|
||||
require.Nil(t, th.App.Srv().Store.Token().Save(token))
|
||||
defer th.App.DeleteToken(token)
|
||||
user, err := th.App.AuthenticateUserForLogin("", th.BasicUser.Username, "", "", token.Token, false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, user)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ func (a *OpenTracingAppLayer) AttachSessionCookies(w http.ResponseWriter, r *htt
|
||||
a.app.AttachSessionCookies(w, r)
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) AuthenticateUserForLogin(id string, loginId string, password string, mfaToken string, ldapOnly bool) (user *model.User, err *model.AppError) {
|
||||
func (a *OpenTracingAppLayer) AuthenticateUserForLogin(id string, loginId string, password string, mfaToken string, cwsToken string, ldapOnly bool) (user *model.User, err *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.AuthenticateUserForLogin")
|
||||
|
||||
@@ -611,7 +611,7 @@ func (a *OpenTracingAppLayer) AuthenticateUserForLogin(id string, loginId string
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.AuthenticateUserForLogin(id, loginId, password, mfaToken, ldapOnly)
|
||||
resultVar0, resultVar1 := a.app.AuthenticateUserForLogin(id, loginId, password, mfaToken, cwsToken, ldapOnly)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
|
||||
@@ -42,6 +42,7 @@ const (
|
||||
TOKEN_TYPE_VERIFY_EMAIL = "verify_email"
|
||||
TOKEN_TYPE_TEAM_INVITATION = "team_invitation"
|
||||
TOKEN_TYPE_GUEST_INVITATION = "guest_invitation"
|
||||
TOKEN_TYPE_CWS_ACCESS = "cws_access_token"
|
||||
PASSWORD_RECOVER_EXPIRY_TIME = 1000 * 60 * 60 // 1 hour
|
||||
INVITATION_EXPIRY_TIME = 1000 * 60 * 60 * 48 // 48 hours
|
||||
IMAGE_PROFILE_PIXEL_DIMENSION = 128
|
||||
|
||||
@@ -2906,6 +2906,10 @@
|
||||
"id": "api.user.login.use_auth_service.app_error",
|
||||
"translation": "Please sign in using {{.AuthService}}."
|
||||
},
|
||||
{
|
||||
"id": "api.user.login_by_cws.invalid_token.app_error",
|
||||
"translation": "CWS token is not valid"
|
||||
},
|
||||
{
|
||||
"id": "api.user.login_by_oauth.bot_login_forbidden.app_error",
|
||||
"translation": "Bot login is forbidden."
|
||||
@@ -2918,6 +2922,10 @@
|
||||
"id": "api.user.login_by_oauth.parse.app_error",
|
||||
"translation": "Could not parse auth data out of {{.Service}} user object."
|
||||
},
|
||||
{
|
||||
"id": "api.user.login_cws.license.error",
|
||||
"translation": "CWS login is forbidden."
|
||||
},
|
||||
{
|
||||
"id": "api.user.login_ldap.not_available.app_error",
|
||||
"translation": "AD/LDAP not available on this server."
|
||||
|
||||
Ссылка в новой задаче
Block a user