diff --git a/app/boot_levels.go b/app/boot_levels.go index e259791da..d31f9edb7 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -543,6 +543,7 @@ func updateAuthSettings(svc authServicer, current *types.AppSettings) { SignupEnabled: current.Auth.Internal.Signup.Enabled, EmailConfirmationRequired: current.Auth.Internal.Signup.EmailConfirmationRequired, PasswordResetEnabled: current.Auth.Internal.PasswordReset.Enabled, + PasswordCreateEnabled: current.Auth.Internal.PasswordCreate.Enabled, SplitCredentialsCheck: current.Auth.Internal.SplitCredentialsCheck, ExternalEnabled: current.Auth.External.Enabled, MultiFactor: authSettings.MultiFactor{ diff --git a/auth/assets/templates/change-password.html.tpl b/auth/assets/templates/change-password.html.tpl index 799fba50a..bd6eee617 100644 --- a/auth/assets/templates/change-password.html.tpl +++ b/auth/assets/templates/change-password.html.tpl @@ -23,7 +23,7 @@ readonly placeholder="{{ tr "change-password.template.form.email.placeholder" }}" value="{{ .user.Email }}" - aria-label="{{ tr "change-password.template.form.email.aria-label" }}"> + aria-label="{{ tr "change-password.template.form.email.label" }}">
diff --git a/auth/assets/templates/create-password.html.tpl b/auth/assets/templates/create-password.html.tpl new file mode 100644 index 000000000..3a52ef272 --- /dev/null +++ b/auth/assets/templates/create-password.html.tpl @@ -0,0 +1,45 @@ +{{ template "inc_header.html.tpl" set . "activeNav" "security" }} +
+

{{ tr "create-password.template.title" }}

+
+ {{ .csrfField }} + {{ if .form.error }} + + {{ end }} +
+ + +
+
+ + +
+
+ +
+
+
+{{ template "inc_footer.html.tpl" . }} diff --git a/auth/assets/templates/scenarios.yaml b/auth/assets/templates/scenarios.yaml index ded9829f0..81ab579c5 100644 --- a/auth/assets/templates/scenarios.yaml +++ b/auth/assets/templates/scenarios.yaml @@ -185,6 +185,12 @@ change-password: form: error: "There was an error..." +create-password: + Default: {} + With error: + form: + error: "There was an error..." + authorized-clients: Empty: {} Full: diff --git a/auth/handlers/handle_change-password.go b/auth/handlers/handle_change-password.go index f7a759dcb..f64e74e92 100644 --- a/auth/handlers/handle_change-password.go +++ b/auth/handlers/handle_change-password.go @@ -27,7 +27,7 @@ func (h *AuthHandlers) changePasswordProc(req *request.AuthReq) (err error) { if err == nil { req.NewAlerts = append(req.NewAlerts, request.Alert{ Type: "primary", - Text: t("change-password.alerts.text"), + Text: t("change-password.alerts.password-change-success"), }) req.RedirectTo = GetLinks().Profile diff --git a/auth/handlers/handle_change-password_test.go b/auth/handlers/handle_change-password_test.go index 54e9f4151..f21a193af 100644 --- a/auth/handlers/handle_change-password_test.go +++ b/auth/handlers/handle_change-password_test.go @@ -63,7 +63,7 @@ func Test_changePasswordProc(t *testing.T) { { name: "successful password change", payload: map[string]string(nil), - alerts: []request.Alert{{Type: "primary", Text: "change-password.alerts.text", Html: ""}}, + alerts: []request.Alert{{Type: "primary", Text: "change-password.alerts.password-change-success", Html: ""}}, link: GetLinks().Profile, fn: func(_ *settings.Settings) { authService = &authServiceMocked{ diff --git a/auth/handlers/handle_create-password.go b/auth/handlers/handle_create-password.go new file mode 100644 index 000000000..e3fd12acd --- /dev/null +++ b/auth/handlers/handle_create-password.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "github.com/cortezaproject/corteza-server/auth/request" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/system/service" + "github.com/cortezaproject/corteza-server/system/types" + "go.uber.org/zap" +) + +var ( + loginLink = GetLinks().Login + profileLink = GetLinks().Profile + createPasswordLink = GetLinks().CreatePassword +) + +func (h *AuthHandlers) createPasswordForm(req *request.AuthReq) (err error) { + h.Log.Debug("password create form") + req.Template = TmplCreatePassword + + // user not set, expecting valid token in URL + if token := req.Request.URL.Query().Get("token"); len(token) > 0 { + var ( + user *types.User + // If user has password or not + passwordSet bool + ) + + user, err = h.AuthService.ValidatePasswordCreateToken(req.Context(), token) + if err == nil { + // If user does not have password (or in case there is no user with such email) + passwordSet = h.AuthService.PasswordSet(req.Context(), user.Email) + if !passwordSet { + // login user + req.AuthUser = request.NewAuthUser(h.Settings, user, false, h.Opt.SessionLifetime) + + // redirect back to self (but without token and with user in session) + h.Log.Debug("valid password create token found, refreshing page with stored user") + req.RedirectTo = createPasswordLink + req.AuthUser.Save(req.Session) + + return nil + } + } + } + + if req.AuthUser == nil || err != nil { + h.Log.Warn("invalid password create token request", zap.Error(err)) + h.invalidPasswordCreateAlert(req, loginLink) + return nil + } + + req.Data["form"] = req.PopKV() + return nil +} + +func (h *AuthHandlers) createPasswordProc(req *request.AuthReq) (err error) { + h.Log.Debug("password create proc") + + err = h.AuthService.SetPassword( + auth.SetIdentityToContext(req.Context(), req.AuthUser.User), + req.AuthUser.User.ID, + req.Request.PostFormValue("password"), + ) + + if err == nil { + t := translator(req, "auth") + req.NewAlerts = append(req.NewAlerts, request.Alert{ + Type: "primary", + Text: t("create-password.alerts.password-create-success"), + }) + + req.RedirectTo = profileLink + return nil + } + + switch { + case service.AuthErrPasswordCreateDisabledByConfig().Is(err): + h.passwordCreateDisabledAlert(req) + + h.Log.Warn("handled error", zap.Error(err)) + return nil + + default: + h.Log.Error("unhandled error", zap.Error(err)) + return err + } +} + +func (h *AuthHandlers) onlyIfPasswordCreateEnabled(fn handlerFn) handlerFn { + return func(req *request.AuthReq) error { + if !h.Settings.PasswordCreateEnabled || !h.Settings.LocalEnabled { + h.passwordCreateDisabledAlert(req) + return nil + } + + return fn(req) + } +} + +func (h *AuthHandlers) passwordCreateDisabledAlert(req *request.AuthReq) { + req.RedirectTo = loginLink + t := translator(req, "auth") + req.NewAlerts = append(req.NewAlerts, request.Alert{ + Type: "danger", + Text: t("create-password.alert.password-create-disabled"), + }) +} + +func (h *AuthHandlers) invalidPasswordCreateAlert(req *request.AuthReq, redirectTo string) { + req.RedirectTo = redirectTo + t := translator(req, "auth") + req.NewAlerts = append(req.NewAlerts, request.Alert{ + Type: "warning", + Text: t("create-password.alerts.invalid-expired-password-token"), + }) +} diff --git a/auth/handlers/handle_create-password_test.go b/auth/handlers/handle_create-password_test.go new file mode 100644 index 000000000..6831a3766 --- /dev/null +++ b/auth/handlers/handle_create-password_test.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "context" + "errors" + "github.com/cortezaproject/corteza-server/system/service" + "net/http" + "net/url" + "testing" + + "github.com/cortezaproject/corteza-server/auth/request" + "github.com/cortezaproject/corteza-server/auth/settings" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/stretchr/testify/require" +) + +func Test_createPasswordForm(t *testing.T) { + var ( + ctx = context.Background() + req = &http.Request{ + URL: &url.URL{}, + } + + user *types.User + authService authService + authHandlers *AuthHandlers + authReq *request.AuthReq + ) + + tcc := []testingExpect{ + { + name: "password create success", + payload: map[string]string(nil), + alerts: []request.Alert(nil), + link: GetLinks().CreatePassword, + template: TmplCreatePassword, + fn: func(_ *settings.Settings) { + req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"} + + authService = &authServiceMocked{ + passwordSet: func(ctx context.Context, s string) bool { + return false + }, + validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) { + u := makeMockUser(ctx) + u.SetRoles() + + return u, nil + }, + } + }, + }, + { + name: "invalid password create token", + payload: map[string]string(nil), + alerts: []request.Alert{{Type: "warning", Text: "create-password.alerts.invalid-expired-password-token"}}, + link: GetLinks().Login, + template: TmplCreatePassword, + fn: func(_ *settings.Settings) { + req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"} + + authService = &authServiceMocked{ + passwordSet: func(ctx context.Context, s string) bool { + return false + }, + validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) { + return nil, errors.New("invalid token") + }, + } + }, + }, + { + name: "invalid password create request", + payload: map[string]string(nil), + alerts: []request.Alert{{Type: "warning", Text: "create-password.alerts.invalid-expired-password-token"}}, + link: GetLinks().Login, + template: TmplCreatePassword, + fn: func(_ *settings.Settings) { + req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"} + user = makeMockUser(ctx) + authService = &authServiceMocked{ + passwordSet: func(ctx context.Context, s string) bool { + return false + }, + validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) { + return nil, errors.New("invalid token") + }, + } + }, + }, + } + + for _, tc := range tcc { + t.Run(tc.name, func(t *testing.T) { + rq := require.New(t) + + // reset from previous + req.Form = url.Values{} + req.PostForm = url.Values{} + + authSettings := &settings.Settings{} + + tc.fn(authSettings) + + authReq = prepareClientAuthReq(ctx, req, user) + authHandlers = prepareClientAuthHandlers(ctx, authService, authSettings) + + // unset so we get to the main functionality + authReq.AuthUser = nil + + err := authHandlers.createPasswordForm(authReq) + + rq.NoError(err) + rq.Equal(tc.template, authReq.Template) + rq.Equal(tc.payload, authReq.GetKV()) + rq.Equal(tc.alerts, authReq.NewAlerts) + rq.Equal(tc.link, authReq.RedirectTo) + }) + } +} + +func Test_createPasswordProc(t *testing.T) { + var ( + ctx = context.Background() + user = makeMockUser(ctx) + + req = &http.Request{} + + authService authService + authHandlers *AuthHandlers + authReq *request.AuthReq + ) + + tcc := []testingExpect{ + { + name: "create password success", + payload: map[string]string(nil), + alerts: []request.Alert{{Type: "primary", Text: "create-password.alerts.password-create-success", Html: ""}}, + link: GetLinks().Profile, + fn: func(_ *settings.Settings) { + authService = &authServiceMocked{ + setPassword: func(ctx context.Context, userID uint64, password string) (err error) { + return nil + }, + } + }, + }, + { + name: "create password disabled", + payload: map[string]string(nil), + alerts: []request.Alert{{Type: "danger", Text: "create-password.alert.password-create-disabled", Html: ""}}, + link: GetLinks().Login, + fn: func(_ *settings.Settings) { + authService = &authServiceMocked{ + setPassword: func(ctx context.Context, userID uint64, password string) (err error) { + return service.AuthErrPasswordCreateDisabledByConfig() + }, + } + }, + }, + } + + for _, tc := range tcc { + t.Run(tc.name, func(t *testing.T) { + rq := require.New(t) + + authSettings := &settings.Settings{} + + tc.fn(authSettings) + + authReq = prepareClientAuthReq(ctx, req, user) + authHandlers = prepareClientAuthHandlers(ctx, authService, authSettings) + + err := authHandlers.createPasswordProc(authReq) + + rq.NoError(err) + rq.Equal(tc.payload, authReq.GetKV()) + rq.Equal(tc.alerts, authReq.NewAlerts) + rq.Equal(tc.link, authReq.RedirectTo) + }) + } +} diff --git a/auth/handlers/handle_password-reset.go b/auth/handlers/handle_password-reset.go index 1a4a81d65..9344cf447 100644 --- a/auth/handlers/handle_password-reset.go +++ b/auth/handlers/handle_password-reset.go @@ -47,30 +47,30 @@ func (h *AuthHandlers) resetPasswordForm(req *request.AuthReq) (err error) { req.Template = TmplResetPassword - if req.AuthUser == nil { - // user not set, expecting valid token in URL - if token := req.Request.URL.Query().Get("token"); len(token) > 0 { - var user *types.User + // user not set, expecting valid token in URL + if token := req.Request.URL.Query().Get("token"); len(token) > 0 { + var user *types.User - user, err = h.AuthService.ValidatePasswordResetToken(req.Context(), token) - if err == nil { - // login user - req.AuthUser = request.NewAuthUser(h.Settings, user, false, h.Opt.SessionLifetime) + user, err = h.AuthService.ValidatePasswordResetToken(req.Context(), token) + if err == nil { + // login user + req.AuthUser = request.NewAuthUser(h.Settings, user, false, h.Opt.SessionLifetime) - // redirect back to self (but without token and with user in session - h.Log.Debug("valid password reset token found, refreshing page with stored user") - req.RedirectTo = GetLinks().ResetPassword - req.AuthUser.Save(req.Session) - return nil - } + // redirect back to self (but without token and with user in session + h.Log.Debug("valid password reset token found, refreshing page with stored user") + req.RedirectTo = GetLinks().ResetPassword + req.AuthUser.Save(req.Session) + return nil } + } + if req.AuthUser == nil || err != nil { h.Log.Warn("invalid password reset token used", zap.Error(err)) req.RedirectTo = GetLinks().RequestPasswordReset t := translator(req, "auth") req.NewAlerts = append(req.NewAlerts, request.Alert{ Type: "warning", - Text: t("password_reset_requested.alert.inv-exp-passw-token"), + Text: t("password-reset-requested.alerts.invalid-expired-password-token"), }) } @@ -87,7 +87,7 @@ func (h *AuthHandlers) resetPasswordProc(req *request.AuthReq) (err error) { t := translator(req, "auth") req.NewAlerts = append(req.NewAlerts, request.Alert{ Type: "primary", - Text: t("password_reset_requested.alert.pass-reset-success"), + Text: t("password-reset-requested.alerts.password-reset-success"), }) req.RedirectTo = GetLinks().Profile @@ -121,6 +121,6 @@ func (h *AuthHandlers) passwordResetDisabledAlert(req *request.AuthReq) { t := translator(req, "auth") req.NewAlerts = append(req.NewAlerts, request.Alert{ Type: "danger", - Text: t("password_reset_requested.alert.pass-reset-disabled"), + Text: t("password-reset-requested.alerts.password-reset-disabled"), }) } diff --git a/auth/handlers/handle_password-reset_test.go b/auth/handlers/handle_password-reset_test.go index a03d66dda..5a9617a45 100644 --- a/auth/handlers/handle_password-reset_test.go +++ b/auth/handlers/handle_password-reset_test.go @@ -80,7 +80,7 @@ func Test_resetPasswordForm(t *testing.T) { { name: "invalid password reset token", payload: map[string]string(nil), - alerts: []request.Alert{{Type: "warning", Text: "password_reset_requested.alert.inv-exp-passw-token"}}, + alerts: []request.Alert{{Type: "warning", Text: "password-reset-requested.alerts.invalid-expired-password-token"}}, link: GetLinks().RequestPasswordReset, template: TmplResetPassword, fn: func(_ *settings.Settings) { @@ -153,7 +153,7 @@ func Test_requestPasswordReset(t *testing.T) { { name: "request reset disabled", payload: map[string]string(nil), - alerts: []request.Alert{{Type: "danger", Text: "password_reset_requested.alert.pass-reset-disabled"}}, + alerts: []request.Alert{{Type: "danger", Text: "password-reset-requested.alerts.password-reset-disabled"}}, link: GetLinks().Login, fn: func(_ *settings.Settings) { authService = &authServiceMocked{ @@ -207,7 +207,7 @@ func Test_requestPasswordProc(t *testing.T) { { name: "reset password success", payload: map[string]string(nil), - alerts: []request.Alert{{Type: "primary", Text: "password_reset_requested.alert.pass-reset-success", Html: ""}}, + alerts: []request.Alert{{Type: "primary", Text: "password-reset-requested.alerts.password-reset-success", Html: ""}}, link: GetLinks().Profile, fn: func(_ *settings.Settings) { authService = &authServiceMocked{ @@ -220,7 +220,7 @@ func Test_requestPasswordProc(t *testing.T) { { name: "reset password disabled", payload: map[string]string(nil), - alerts: []request.Alert{{Type: "danger", Text: "password_reset_requested.alert.pass-reset-disabled", Html: ""}}, + alerts: []request.Alert{{Type: "danger", Text: "password-reset-requested.alerts.password-reset-disabled", Html: ""}}, link: GetLinks().Login, fn: func(_ *settings.Settings) { authService = &authServiceMocked{ diff --git a/auth/handlers/handle_signup.go b/auth/handlers/handle_signup.go index 261667acf..691ab2752 100644 --- a/auth/handlers/handle_signup.go +++ b/auth/handlers/handle_signup.go @@ -135,7 +135,7 @@ func (h *AuthHandlers) confirmEmail(req *request.AuthReq) (err error) { t := translator(req, "auth") req.NewAlerts = append(req.NewAlerts, request.Alert{ Type: "warning", - Text: t("signup.alerts.inv-or-exp-token"), + Text: t("signup.alerts.invalid-expired-token"), }) return nil diff --git a/auth/handlers/handler.go b/auth/handlers/handler.go index ceea231a3..e8c7a4543 100644 --- a/auth/handlers/handler.go +++ b/auth/handlers/handler.go @@ -35,6 +35,7 @@ type ( ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error) ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) + ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) SendPasswordResetToken(ctx context.Context, email string) (err error) GetProviders() types.ExternalAuthProviderSet @@ -109,6 +110,7 @@ type ( const ( TmplAuthorizedClients = "authorized-clients.html.tpl" TmplChangePassword = "change-password.html.tpl" + TmplCreatePassword = "create-password.html.tpl" TmplLogin = "login.html.tpl" TmplLogout = "logout.html.tpl" TmplOAuth2AuthorizeClient = "oauth2-authorize-client.html.tpl" diff --git a/auth/handlers/links.go b/auth/handlers/links.go index 423df5be9..8177a0946 100644 --- a/auth/handlers/links.go +++ b/auth/handlers/links.go @@ -12,6 +12,7 @@ type ( Security, ChangePassword, + CreatePassword, RequestPasswordReset, PasswordResetRequested, @@ -58,6 +59,7 @@ func GetLinks() Links { Login: b + "auth/login", Security: b + "auth/security", ChangePassword: b + "auth/change-password", + CreatePassword: b + "auth/create-password", RequestPasswordReset: b + "auth/request-password-reset", PasswordResetRequested: b + "auth/password-reset-requested", ResetPassword: b + "auth/reset-password", diff --git a/auth/handlers/mock_test.go b/auth/handlers/mock_test.go index b534a6578..2ff183c64 100644 --- a/auth/handlers/mock_test.go +++ b/auth/handlers/mock_test.go @@ -85,8 +85,10 @@ type ( internalLogin func(context.Context, string, string) (u *types.User, err error) setPassword func(context.Context, uint64, string) (err error) changePassword func(context.Context, uint64, string, string) (err error) + createPassword func(context.Context, uint64, string) (err error) validateEmailConfirmationToken func(context.Context, string) (user *types.User, err error) validatePasswordResetToken func(context.Context, string) (user *types.User, err error) + validatePasswordCreateToken func(context.Context, string) (user *types.User, err error) sendEmailAddressConfirmationToken func(context.Context, *types.User) (err error) sendPasswordResetToken func(context.Context, string) (err error) passwordSet func(context.Context, string) bool @@ -138,6 +140,10 @@ func (s authServiceMocked) ValidatePasswordResetToken(ctx context.Context, token return s.validatePasswordResetToken(ctx, token) } +func (s authServiceMocked) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) { + return s.validatePasswordCreateToken(ctx, token) +} + func (s authServiceMocked) PasswordSet(ctx context.Context, email string) (is bool) { return s.passwordSet(ctx, email) } @@ -248,6 +254,13 @@ func (ma mockAuthService) ValidatePasswordResetToken(ctx context.Context, token return &types.User{ID: 123}, nil } +// +// Mocking authService +// +func (ma mockAuthService) ValidatePasswordCreateToken(ctx context.Context, token string) (*types.User, error) { + return &types.User{ID: 123}, nil +} + func (ma mockAuthService) SendEmailOTP(ctx context.Context) error { return nil } @@ -268,6 +281,10 @@ func (m mockNotificationService) PasswordReset(ctx context.Context, emailAddress return nil } +func (m mockNotificationService) PasswordCreate(ctx context.Context, emailAddress string, token string) (string, error) { + return "", nil +} + func (m mockNotificationService) EmailOTP(ctx context.Context, emailAddress string, code string) error { return nil } diff --git a/auth/handlers/routes.go b/auth/handlers/routes.go index 3c507b7e4..6ffa3f25c 100644 --- a/auth/handlers/routes.go +++ b/auth/handlers/routes.go @@ -80,6 +80,8 @@ func (h *AuthHandlers) MountHttpRoutes(r chi.Router) { r.Post(tbp(l.Security), h.handle(authOnly(h.securityProc))) r.Get(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm)))) r.Post(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc)))) + r.Get(tbp(l.CreatePassword), h.handle(h.onlyIfPasswordCreateEnabled(h.createPasswordForm))) + r.Post(tbp(l.CreatePassword), h.handle(h.onlyIfPasswordCreateEnabled(h.createPasswordProc))) r.Get(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigForm))) r.Post(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigProc))) diff --git a/auth/settings/settings.go b/auth/settings/settings.go index ace7474b0..76b4a093d 100644 --- a/auth/settings/settings.go +++ b/auth/settings/settings.go @@ -6,6 +6,7 @@ type ( SignupEnabled bool EmailConfirmationRequired bool PasswordResetEnabled bool + PasswordCreateEnabled bool ExternalEnabled bool SplitCredentialsCheck bool Providers []Provider diff --git a/pkg/rand/rand.go b/pkg/rand/rand.go index 1087d5a0e..fe22e867e 100644 --- a/pkg/rand/rand.go +++ b/pkg/rand/rand.go @@ -9,10 +9,12 @@ import ( // credits: https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang const ( - letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - letterIdxBits = 6 // 6 bits to represent a letter index - letterIdxMask = 1< 0 && !authSvc.CheckPasswordStrength(string(password)) { @@ -134,7 +137,19 @@ func Users(ctx context.Context, app serviceInitializer) *cobra.Command { cli.HandleError(err) } + // generate the create password link of user + if !flagNoPassword && len(password) == 0 && flagMakePasswordLink { + url, err = authSvc.GeneratePasswordCreateToken(ctx, user.Email) + if err != nil { + cli.HandleError(err) + } + } + cmd.Printf("User created [%d].\n", user.ID) + if flagMakePasswordLink && len(url) > 0 { + cmd.Println("Set password by clicking here:") + cmd.Printf("%s\n", url) + } if len(mm) > 0 { _ = mm.Walk(func(m *types.RoleMember) error { @@ -165,6 +180,12 @@ func Users(ctx context.Context, app serviceInitializer) *cobra.Command { "", "Provide password (as alternative to interactive way)") + addCmd.Flags().BoolVar( + &flagMakePasswordLink, + "make-password-link", + false, + "Provide link to create password") + addCmd.Flags().StringSliceVar( &flagRoles, "role", diff --git a/system/service/auth.go b/system/service/auth.go index 1d3323f1e..4d1be0804 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -48,6 +48,7 @@ const ( credentialsTypeEmailAuthToken = "email-authentication-token" credentialsTypeResetPasswordToken = "password-reset-token" credentialsTypeResetPasswordTokenExchanged = "password-reset-token-exchanged" + credentialsTypeCreatePasswordToken = "password-create-token" credentialsTypeMfaTotpSecret = "mfa-totp-secret" credentialsTypeMFAEmailOTP = "mfa-email-otp" @@ -676,6 +677,11 @@ func (svc auth) ValidatePasswordResetToken(ctx context.Context, token string) (u return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeResetPasswordToken) } +// ValidatePasswordCreateToken validates password create token +func (svc auth) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) { + return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeCreatePasswordToken) +} + // PasswordSet checks and returns true if user's password is set // False is also returned in case user does not exist. func (svc *auth) PasswordSet(ctx context.Context, email string) (is bool) { @@ -840,6 +846,47 @@ func (svc auth) sendPasswordResetToken(ctx context.Context, u *types.User) (err return svc.notifications.PasswordReset(ctx, u.Email, token) } +// GeneratePasswordCreateToken generates password create token +func (svc auth) GeneratePasswordCreateToken(ctx context.Context, email string) (url string, err error) { + var ( + u *types.User + + aam = &authActionProps{ + user: u, + email: email, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordCreate.Enabled { + return AuthErrPasswordCreateDisabledByConfig(aam) + } + + if u, err = store.LookupUserByEmail(ctx, svc.store, email); err != nil { + return err + } + + ctx = internalAuth.SetIdentityToContext(ctx, u) + + if url, err = svc.sendPasswordCreateToken(ctx, u); err != nil { + return err + } + + return nil + }() + + return url, svc.recordAction(ctx, aam, AuthActionGeneratePasswordCreateToken, err) +} + +func (svc auth) sendPasswordCreateToken(ctx context.Context, u *types.User) (url string, err error) { + token, err := svc.createUserToken(ctx, u, credentialsTypeCreatePasswordToken) + if err != nil { + return + } + + return svc.notifications.PasswordCreate(token) +} + // procLogin fn performs standard validation, credentials-update tasks and triggers events func (svc auth) procLogin(ctx context.Context, s store.Storer, u *types.User, c *types.Credentials, p *types.AuthProvider) (err error) { if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeLogin(u, p)); err != nil { @@ -960,6 +1007,16 @@ func (svc auth) createUserToken(ctx context.Context, u *types.User, kind string) // random number, 6 chars token = fmt.Sprintf("%06d", rand2.Int())[0:6] + case credentialsTypeCreatePasswordToken: + expSec := svc.settings.Auth.Internal.PasswordCreate.Expires + if expSec == 0 { + expSec = 24 + } + + expiresAt = now().Add(time.Hour * time.Duration(expSec)) + + // random password string, "3i[g0|)z" + token = fmt.Sprintf("%s", rand.Password(credentialsTokenLength)) default: // 1h expiration for all tokens send via email expiresAt = now().Add(time.Minute * 60) diff --git a/system/service/auth_actions.gen.go b/system/service/auth_actions.gen.go index 691630dc1..cade20af2 100644 --- a/system/service/auth_actions.gen.go +++ b/system/service/auth_actions.gen.go @@ -456,6 +456,26 @@ func AuthActionExchangePasswordResetToken(props ...*authActionProps) *authAction return a } +// AuthActionGeneratePasswordCreateToken returns "system:auth.generatePasswordCreateToken" action +// +// This function is auto-generated. +// +func AuthActionGeneratePasswordCreateToken(props ...*authActionProps) *authAction { + a := &authAction{ + timestamp: time.Now(), + resource: "system:auth", + action: "generatePasswordCreateToken", + log: "password create token generated for {{email}}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + // AuthActionAutoPromote returns "system:auth.autoPromote" action // // This function is auto-generated. @@ -1092,6 +1112,40 @@ func AuthErrPasswodResetFailedOldPasswordCheckFailed(mm ...*authActionProps) *er return e } +// AuthErrPasswordCreateFailedForUnknownUser returns "system:auth.passwordCreateFailedForUnknownUser" as *errors.Error +// +// +// This function is auto-generated. +// +func AuthErrPasswordCreateFailedForUnknownUser(mm ...*authActionProps) *errors.Error { + var p = &authActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("failed to create password for the unknown user", nil), + + errors.Meta("type", "passwordCreateFailedForUnknownUser"), + errors.Meta("resource", "system:auth"), + + errors.Meta(authPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "system"), + errors.Meta(locale.ErrorMetaKey{}, "auth.errors.passwordCreateFailedForUnknownUser"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // AuthErrPasswordResetDisabledByConfig returns "system:auth.passwordResetDisabledByConfig" as *errors.Error // // @@ -1126,6 +1180,40 @@ func AuthErrPasswordResetDisabledByConfig(mm ...*authActionProps) *errors.Error return e } +// AuthErrPasswordCreateDisabledByConfig returns "system:auth.passwordCreateDisabledByConfig" as *errors.Error +// +// +// This function is auto-generated. +// +func AuthErrPasswordCreateDisabledByConfig(mm ...*authActionProps) *errors.Error { + var p = &authActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("password create is disabled", nil), + + errors.Meta("type", "passwordCreateDisabledByConfig"), + errors.Meta("resource", "system:auth"), + + errors.Meta(authPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "system"), + errors.Meta(locale.ErrorMetaKey{}, "auth.errors.passwordCreateDisabledByConfig"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // AuthErrPasswordNotSecure returns "system:auth.passwordNotSecure" as *errors.Error // // diff --git a/system/service/auth_actions.yaml b/system/service/auth_actions.yaml index 7cddeaa6a..2a2f178e5 100644 --- a/system/service/auth_actions.yaml +++ b/system/service/auth_actions.yaml @@ -56,6 +56,9 @@ actions: - action: exchangePasswordResetToken log: "password reset token exchanged" + - action: generatePasswordCreateToken + log: "password create token generated for {{email}}" + - action: autoPromote log: "auto-promoted to {{role}}" @@ -128,9 +131,15 @@ errors: - error: passwodResetFailedOldPasswordCheckFailed message: "failed to change password, old password does not match" + - error: passwordCreateFailedForUnknownUser + message: "failed to create password for the unknown user" + - error: passwordResetDisabledByConfig message: "password reset is disabled" + - error: passwordCreateDisabledByConfig + message: "password create is disabled" + - error: passwordNotSecure message: "provided password is not secure; use longer password with more non-alphanumeric character" diff --git a/system/service/auth_notification.go b/system/service/auth_notification.go index f0d35dac9..b5ae4a802 100644 --- a/system/service/auth_notification.go +++ b/system/service/auth_notification.go @@ -29,6 +29,7 @@ type ( EmailOTP(ctx context.Context, emailAddress string, otp string) error EmailConfirmation(ctx context.Context, emailAddress string, url string) error PasswordReset(ctx context.Context, emailAddress string, url string) error + PasswordCreate(url string) (string, error) } ) @@ -63,6 +64,10 @@ func (svc authNotification) PasswordReset(ctx context.Context, emailAddress stri }) } +func (svc authNotification) PasswordCreate(token string) (string, error) { + return fmt.Sprintf("%s/create-password?token=%s", svc.opt.BaseURL, url.QueryEscape(token)), nil +} + func (svc authNotification) newMail() *gomail.Message { var ( m = mail.New() diff --git a/system/types/app_settings.go b/system/types/app_settings.go index 97c5277db..986f84193 100644 --- a/system/types/app_settings.go +++ b/system/types/app_settings.go @@ -50,6 +50,15 @@ type ( // Can users reset their passwords PasswordReset struct{ Enabled bool } `kv:"password-reset"` + // PasswordCreate setting for create password for user via generated link with token + // If user has no password then link redirects to create password page + // Otherwise it redirects to profile page of that user + // link can be generated through useradd cli command with `make-password-link` flag + PasswordCreate struct { + Enabled bool + Expires uint + } `kv:"password-create"` + // Splits credentials check into 2 parts // If user has password credentials it offers him to enter the password // Otherwise we offer the user to choose among the enabled external providers