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" }}
+
+
+{{ 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