Extends user add cli command
- Adds flag `make-password-link` to generate temp token to create users password - Adds route and handler for create user password - Updates few auth label translation reference - Fixes reset-password issue with existing login session - Updates tests
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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" }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>
|
||||
@@ -36,7 +36,7 @@
|
||||
name="oldPassword"
|
||||
autocomplete="current-password"
|
||||
placeholder="{{ tr "change-password.template.form.old-password.placeholder" }}"
|
||||
aria-label="{{ tr "change-password.template.form.old-password.aria-label" }}">
|
||||
aria-label="{{ tr "change-password.template.form.old-password.label" }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>
|
||||
@@ -49,7 +49,7 @@
|
||||
name="newPassword"
|
||||
autocomplete="new-password"
|
||||
placeholder="{{ tr "change-password.template.form.new-password.placeholder" }}"
|
||||
aria-label="{{ tr "change-password.template.form.new-password.aria-label" }}">
|
||||
aria-label="{{ tr "change-password.template.form.new-password.label" }}">
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-block btn-lg" type="submit">{{ tr "change-password.template.form.button.change-password" }}</button>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{{ template "inc_header.html.tpl" set . "activeNav" "security" }}
|
||||
<div class="card-body p-0">
|
||||
<h1 class="h4 card-title p-3 border-bottom">{{ tr "create-password.template.title" }}</h1>
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ links.CreatePassword }}"
|
||||
class="p-3"
|
||||
>
|
||||
{{ .csrfField }}
|
||||
{{ if .form.error }}
|
||||
<div class="text-danger font-weight-bold mb-3" role="alert">
|
||||
{{ .form.error }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="mb-3">
|
||||
<label>
|
||||
{{ tr "create-password.template.form.email.label" }}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
name="email"
|
||||
readonly
|
||||
value="{{ .user.Email }}"
|
||||
aria-label="{{ tr "create-password.template.form.email.label" }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>
|
||||
{{ tr "create-password.template.form.password.label" }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
class="form-control"
|
||||
name="password"
|
||||
autocomplete="password"
|
||||
placeholder="{{ tr "create-password.template.form.password.placeholder" }}"
|
||||
aria-label="{{ tr "create-password.template.form.password.label" }}">
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-block btn-lg" type="submit">{{ tr "create-password.template.form.button.create-password" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ template "inc_footer.html.tpl" . }}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -6,6 +6,7 @@ type (
|
||||
SignupEnabled bool
|
||||
EmailConfirmationRequired bool
|
||||
PasswordResetEnabled bool
|
||||
PasswordCreateEnabled bool
|
||||
ExternalEnabled bool
|
||||
SplitCredentialsCheck bool
|
||||
Providers []Provider
|
||||
|
||||
+30
-4
@@ -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<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
|
||||
letterSpecials = "~=+%^*/()[]{}/!@#$?|"
|
||||
letterDigits = "0123456789"
|
||||
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + letterDigits
|
||||
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
|
||||
)
|
||||
|
||||
var randSrc = rand.NewSource(time.Now().UnixNano())
|
||||
@@ -39,3 +41,27 @@ func Bytes(n int) []byte {
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Password generates a random ASCII string with at least one digit and one special character
|
||||
func Password(n int) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
b := make([]byte, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
var s string
|
||||
if i == 0 {
|
||||
s = letterDigits
|
||||
} else if i == 1 {
|
||||
s = letterSpecials
|
||||
} else {
|
||||
s = letterBytes
|
||||
}
|
||||
b[i] = s[rand.Intn(len(s))]
|
||||
}
|
||||
rand.Shuffle(len(b), func(i, j int) {
|
||||
b[i], b[j] = b[j], b[i]
|
||||
})
|
||||
return string(b) // E.g. "3i[g0|)z"
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ import (
|
||||
|
||||
func Users(ctx context.Context, app serviceInitializer) *cobra.Command {
|
||||
var (
|
||||
flagNoPassword bool
|
||||
flagPassword string
|
||||
flagRoles []string
|
||||
flagNoPassword bool
|
||||
flagPassword string
|
||||
flagMakePasswordLink bool
|
||||
flagRoles []string
|
||||
)
|
||||
|
||||
// User management commands.
|
||||
@@ -101,6 +102,9 @@ func Users(ctx context.Context, app serviceInitializer) *cobra.Command {
|
||||
|
||||
err error
|
||||
|
||||
// url for to create password(make-password-link)
|
||||
url string
|
||||
|
||||
// Use provided password...
|
||||
password = []byte(flagPassword)
|
||||
|
||||
@@ -108,12 +112,11 @@ func Users(ctx context.Context, app serviceInitializer) *cobra.Command {
|
||||
mm types.RoleMemberSet
|
||||
)
|
||||
|
||||
if !flagNoPassword && len(password) == 0 {
|
||||
if !flagNoPassword && !flagMakePasswordLink && len(password) == 0 {
|
||||
cmd.Print("Set password: ")
|
||||
if password, err = terminal.ReadPassword(syscall.Stdin); err != nil {
|
||||
cli.HandleError(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if len(password) > 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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+88
@@ -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
|
||||
//
|
||||
//
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user