diff --git a/controller/config/config.go b/controller/config/config.go index 7f717aa1..aeeea4f7 100644 --- a/controller/config/config.go +++ b/controller/config/config.go @@ -1,12 +1,13 @@ package config import ( + "time" + "github.com/openziti/zrok/controller/emailUi" "github.com/openziti/zrok/controller/env" "github.com/openziti/zrok/controller/limits" "github.com/openziti/zrok/controller/metrics" "github.com/openziti/zrok/controller/zrokEdgeSdk" - "time" "github.com/michaelquigley/cf" "github.com/openziti/zrok/controller/store" @@ -16,18 +17,19 @@ import ( const ConfigVersion = 3 type Config struct { - V int - Admin *AdminConfig - Bridge *metrics.BridgeConfig - Endpoint *EndpointConfig - Email *emailUi.Config - Limits *limits.Config - Maintenance *MaintenanceConfig - Metrics *metrics.Config - Registration *RegistrationConfig - ResetPassword *ResetPasswordConfig - Store *store.Config - Ziti *zrokEdgeSdk.Config + V int + Admin *AdminConfig + Bridge *metrics.BridgeConfig + Endpoint *EndpointConfig + Email *emailUi.Config + Limits *limits.Config + Maintenance *MaintenanceConfig + Metrics *metrics.Config + Registration *RegistrationConfig + ResetPassword *ResetPasswordConfig + Store *store.Config + Ziti *zrokEdgeSdk.Config + PasswordRequirements *PaswordRequirementsConfig } type AdminConfig struct { @@ -47,6 +49,14 @@ type RegistrationConfig struct { RegistrationUrlTemplate string } +type PaswordRequirementsConfig struct { + Length int + RequireCapital bool + RequireNumeric bool + RequireSpecial bool + ValidSpecialCharacters string +} + type ResetPasswordConfig struct { ResetUrlTemplate string } @@ -71,6 +81,13 @@ type ResetPasswordMaintenanceConfig struct { func DefaultConfig() *Config { return &Config{ Limits: limits.DefaultConfig(), + PasswordRequirements: &PaswordRequirementsConfig{ + Length: 8, + RequireCapital: true, + RequireNumeric: true, + RequireSpecial: true, + ValidSpecialCharacters: `!@$&*_-., "#%'()+/:;<=>?[\]^{|}~`, + }, Maintenance: &MaintenanceConfig{ ResetPassword: &ResetPasswordMaintenanceConfig{ ExpirationTimeout: time.Minute * 15, diff --git a/controller/configuration.go b/controller/configuration.go index 4e31810f..66430af1 100644 --- a/controller/configuration.go +++ b/controller/configuration.go @@ -28,5 +28,14 @@ func (ch *configurationHandler) Handle(_ metadata.ConfigurationParams) middlewar data.TouLink = cfg.Admin.TouLink data.InviteTokenContact = cfg.Admin.InviteTokenContact } + if cfg.PasswordRequirements != nil { + data.PasswordRequirements = &rest_model_zrok.PasswordRequirements{ + Length: int64(cfg.PasswordRequirements.Length), + RequireCapital: cfg.PasswordRequirements.RequireCapital, + RequireNumeric: cfg.PasswordRequirements.RequireNumeric, + RequireSpecial: cfg.PasswordRequirements.RequireSpecial, + ValidSpecialCharacters: cfg.PasswordRequirements.ValidSpecialCharacters, + } + } return metadata.NewConfigurationOK().WithPayload(data) } diff --git a/controller/controller.go b/controller/controller.go index 15288e3b..712d234f 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -2,6 +2,7 @@ package controller import ( "context" + "github.com/openziti/zrok/controller/config" "github.com/openziti/zrok/controller/limits" "github.com/openziti/zrok/controller/metrics" @@ -34,8 +35,8 @@ func Run(inCfg *config.Config) error { api.KeyAuth = newZrokAuthenticator(cfg).authenticate api.AccountInviteHandler = newInviteHandler(cfg) api.AccountLoginHandler = account.LoginHandlerFunc(loginHandler) - api.AccountRegisterHandler = newRegisterHandler() - api.AccountResetPasswordHandler = newResetPasswordHandler() + api.AccountRegisterHandler = newRegisterHandler(cfg) + api.AccountResetPasswordHandler = newResetPasswordHandler(cfg) api.AccountResetPasswordRequestHandler = newResetPasswordRequestHandler() api.AccountVerifyHandler = newVerifyHandler() api.AdminCreateFrontendHandler = newCreateFrontendHandler() diff --git a/controller/register.go b/controller/register.go index 6ffdf50b..ad3fa36a 100644 --- a/controller/register.go +++ b/controller/register.go @@ -2,16 +2,21 @@ package controller import ( "github.com/go-openapi/runtime/middleware" + "github.com/openziti/zrok/controller/config" "github.com/openziti/zrok/controller/store" "github.com/openziti/zrok/rest_model_zrok" "github.com/openziti/zrok/rest_server_zrok/operations/account" "github.com/sirupsen/logrus" ) -type registerHandler struct{} +type registerHandler struct { + cfg *config.Config +} -func newRegisterHandler() *registerHandler { - return ®isterHandler{} +func newRegisterHandler(cfg *config.Config) *registerHandler { + return ®isterHandler{ + cfg: cfg, + } } func (h *registerHandler) Handle(params account.RegisterParams) middleware.Responder { if params.Body == nil || params.Body.Token == "" || params.Body.Password == "" { @@ -38,6 +43,12 @@ func (h *registerHandler) Handle(params account.RegisterParams) middleware.Respo logrus.Errorf("error creating token for request '%v' (%v): %v", params.Body.Token, ar.Email, err) return account.NewRegisterInternalServerError() } + + if err := validatePassword(h.cfg, params.Body.Password); err != nil { + logrus.Errorf("password not valid for request '%v', (%v): %v", params.Body.Token, ar.Email, err) + return account.NewRegisterUnprocessableEntity().WithPayload(rest_model_zrok.ErrorMessage(err.Error())) + } + hpwd, err := hashPassword(params.Body.Password) if err != nil { logrus.Errorf("error hashing password for request '%v' (%v): %v", params.Body.Token, ar.Email, err) diff --git a/controller/resetPassword.go b/controller/resetPassword.go index f34a942e..5327e3c7 100644 --- a/controller/resetPassword.go +++ b/controller/resetPassword.go @@ -2,14 +2,20 @@ package controller import ( "github.com/go-openapi/runtime/middleware" + "github.com/openziti/zrok/controller/config" + "github.com/openziti/zrok/rest_model_zrok" "github.com/openziti/zrok/rest_server_zrok/operations/account" "github.com/sirupsen/logrus" ) -type resetPasswordHandler struct{} +type resetPasswordHandler struct { + cfg *config.Config +} -func newResetPasswordHandler() *resetPasswordHandler { - return &resetPasswordHandler{} +func newResetPasswordHandler(cfg *config.Config) *resetPasswordHandler { + return &resetPasswordHandler{ + cfg: cfg, + } } func (handler *resetPasswordHandler) Handle(params account.ResetPasswordParams) middleware.Responder { @@ -41,6 +47,12 @@ func (handler *resetPasswordHandler) Handle(params account.ResetPasswordParams) logrus.Errorf("account '%v' for '%v' deleted", a.Email, a.Token) return account.NewResetPasswordNotFound() } + + if err := validatePassword(handler.cfg, params.Body.Password); err != nil { + logrus.Errorf("password not valid for request '%v', (%v): %v", params.Body.Token, a.Email, err) + return account.NewResetPasswordUnprocessableEntity().WithPayload(rest_model_zrok.ErrorMessage(err.Error())) + } + hpwd, err := hashPassword(params.Body.Password) if err != nil { logrus.Errorf("error hashing password for '%v' (%v): %v", params.Body.Token, a.Email, err) diff --git a/controller/util.go b/controller/util.go index 68e55a6c..9d934df3 100644 --- a/controller/util.go +++ b/controller/util.go @@ -1,13 +1,16 @@ package controller import ( + "fmt" + "net/http" + "strings" + "unicode" + errors2 "github.com/go-openapi/errors" "github.com/jaevor/go-nanoid" "github.com/openziti/zrok/controller/config" "github.com/openziti/zrok/rest_model_zrok" "github.com/sirupsen/logrus" - "net/http" - "strings" ) type zrokAuthenticator struct { @@ -87,3 +90,43 @@ func realRemoteAddress(req *http.Request) string { func proxyUrl(shrToken, template string) string { return strings.Replace(template, "{token}", shrToken, -1) } + +func validatePassword(cfg *config.Config, password string) error { + if cfg.PasswordRequirements.Length > len(password) { + return fmt.Errorf("password length: expected (%d), got (%d)", cfg.PasswordRequirements.Length, len(password)) + } + if cfg.PasswordRequirements.RequireCapital { + if !hasCapital(password) { + return fmt.Errorf("password requires capital, found none") + } + } + if cfg.PasswordRequirements.RequireNumeric { + if !hasNumeric(password) { + return fmt.Errorf("password requires numeric, found none") + } + } + if cfg.PasswordRequirements.RequireSpecial { + if !strings.ContainsAny(password, cfg.PasswordRequirements.ValidSpecialCharacters) { + return fmt.Errorf("password requires special character, found none") + } + } + return nil +} + +func hasCapital(check string) bool { + for _, c := range check { + if unicode.IsUpper(c) { + return true + } + } + return false +} + +func hasNumeric(check string) bool { + for _, c := range check { + if unicode.IsDigit(c) { + return true + } + } + return false +} diff --git a/etc/ctrl.yml b/etc/ctrl.yml index 4e9af002..2e8792c0 100644 --- a/etc/ctrl.yml +++ b/etc/ctrl.yml @@ -145,6 +145,16 @@ metrics: registration: registration_url_template: https://zrok.server.com/register +# Configure password requirements for user accounts. +# +password_requirements: + length: 8 + require_capital: true + require_numeric: true + require_special: true + # Denote which characters satisfy the `require_special` requirement. Note the need to escape specific characters. + valid_special_characters: "\"\\`'~!@#$%^&*()[],./" + # Configure the generated URL for password resets. The reset token will be appended to this URL. # reset_password: diff --git a/rest_client_zrok/account/register_responses.go b/rest_client_zrok/account/register_responses.go index 9a5d2488..82225bbe 100644 --- a/rest_client_zrok/account/register_responses.go +++ b/rest_client_zrok/account/register_responses.go @@ -35,6 +35,12 @@ func (o *RegisterReader) ReadResponse(response runtime.ClientResponse, consumer return nil, err } return nil, result + case 422: + result := NewRegisterUnprocessableEntity() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result case 500: result := NewRegisterInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { @@ -160,6 +166,67 @@ func (o *RegisterNotFound) readResponse(response runtime.ClientResponse, consume return nil } +// NewRegisterUnprocessableEntity creates a RegisterUnprocessableEntity with default headers values +func NewRegisterUnprocessableEntity() *RegisterUnprocessableEntity { + return &RegisterUnprocessableEntity{} +} + +/* +RegisterUnprocessableEntity describes a response with status code 422, with default header values. + +password validation failure +*/ +type RegisterUnprocessableEntity struct { + Payload rest_model_zrok.ErrorMessage +} + +// IsSuccess returns true when this register unprocessable entity response has a 2xx status code +func (o *RegisterUnprocessableEntity) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this register unprocessable entity response has a 3xx status code +func (o *RegisterUnprocessableEntity) IsRedirect() bool { + return false +} + +// IsClientError returns true when this register unprocessable entity response has a 4xx status code +func (o *RegisterUnprocessableEntity) IsClientError() bool { + return true +} + +// IsServerError returns true when this register unprocessable entity response has a 5xx status code +func (o *RegisterUnprocessableEntity) IsServerError() bool { + return false +} + +// IsCode returns true when this register unprocessable entity response a status code equal to that given +func (o *RegisterUnprocessableEntity) IsCode(code int) bool { + return code == 422 +} + +func (o *RegisterUnprocessableEntity) Error() string { + return fmt.Sprintf("[POST /register][%d] registerUnprocessableEntity %+v", 422, o.Payload) +} + +func (o *RegisterUnprocessableEntity) String() string { + return fmt.Sprintf("[POST /register][%d] registerUnprocessableEntity %+v", 422, o.Payload) +} + +func (o *RegisterUnprocessableEntity) GetPayload() rest_model_zrok.ErrorMessage { + return o.Payload +} + +func (o *RegisterUnprocessableEntity) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + // NewRegisterInternalServerError creates a RegisterInternalServerError with default headers values func NewRegisterInternalServerError() *RegisterInternalServerError { return &RegisterInternalServerError{} diff --git a/rest_client_zrok/account/reset_password_responses.go b/rest_client_zrok/account/reset_password_responses.go index 84cbdf90..fb106b41 100644 --- a/rest_client_zrok/account/reset_password_responses.go +++ b/rest_client_zrok/account/reset_password_responses.go @@ -7,9 +7,12 @@ package account import ( "fmt" + "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" + + "github.com/openziti/zrok/rest_model_zrok" ) // ResetPasswordReader is a Reader for the ResetPassword structure. @@ -32,6 +35,12 @@ func (o *ResetPasswordReader) ReadResponse(response runtime.ClientResponse, cons return nil, err } return nil, result + case 422: + result := NewResetPasswordUnprocessableEntity() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result case 500: result := NewResetPasswordInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { @@ -145,6 +154,67 @@ func (o *ResetPasswordNotFound) readResponse(response runtime.ClientResponse, co return nil } +// NewResetPasswordUnprocessableEntity creates a ResetPasswordUnprocessableEntity with default headers values +func NewResetPasswordUnprocessableEntity() *ResetPasswordUnprocessableEntity { + return &ResetPasswordUnprocessableEntity{} +} + +/* +ResetPasswordUnprocessableEntity describes a response with status code 422, with default header values. + +password validation failure +*/ +type ResetPasswordUnprocessableEntity struct { + Payload rest_model_zrok.ErrorMessage +} + +// IsSuccess returns true when this reset password unprocessable entity response has a 2xx status code +func (o *ResetPasswordUnprocessableEntity) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this reset password unprocessable entity response has a 3xx status code +func (o *ResetPasswordUnprocessableEntity) IsRedirect() bool { + return false +} + +// IsClientError returns true when this reset password unprocessable entity response has a 4xx status code +func (o *ResetPasswordUnprocessableEntity) IsClientError() bool { + return true +} + +// IsServerError returns true when this reset password unprocessable entity response has a 5xx status code +func (o *ResetPasswordUnprocessableEntity) IsServerError() bool { + return false +} + +// IsCode returns true when this reset password unprocessable entity response a status code equal to that given +func (o *ResetPasswordUnprocessableEntity) IsCode(code int) bool { + return code == 422 +} + +func (o *ResetPasswordUnprocessableEntity) Error() string { + return fmt.Sprintf("[POST /resetPassword][%d] resetPasswordUnprocessableEntity %+v", 422, o.Payload) +} + +func (o *ResetPasswordUnprocessableEntity) String() string { + return fmt.Sprintf("[POST /resetPassword][%d] resetPasswordUnprocessableEntity %+v", 422, o.Payload) +} + +func (o *ResetPasswordUnprocessableEntity) GetPayload() rest_model_zrok.ErrorMessage { + return o.Payload +} + +func (o *ResetPasswordUnprocessableEntity) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + // NewResetPasswordInternalServerError creates a ResetPasswordInternalServerError with default headers values func NewResetPasswordInternalServerError() *ResetPasswordInternalServerError { return &ResetPasswordInternalServerError{} diff --git a/rest_model_zrok/configuration.go b/rest_model_zrok/configuration.go index 8486455a..fad8c738 100644 --- a/rest_model_zrok/configuration.go +++ b/rest_model_zrok/configuration.go @@ -8,6 +8,7 @@ package rest_model_zrok import ( "context" + "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) @@ -23,6 +24,9 @@ type Configuration struct { // invites open InvitesOpen bool `json:"invitesOpen,omitempty"` + // password requirements + PasswordRequirements *PasswordRequirements `json:"passwordRequirements,omitempty"` + // requires invite token RequiresInviteToken bool `json:"requiresInviteToken,omitempty"` @@ -35,11 +39,64 @@ type Configuration struct { // Validate validates this configuration func (m *Configuration) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validatePasswordRequirements(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } return nil } -// ContextValidate validates this configuration based on context it is used +func (m *Configuration) validatePasswordRequirements(formats strfmt.Registry) error { + if swag.IsZero(m.PasswordRequirements) { // not required + return nil + } + + if m.PasswordRequirements != nil { + if err := m.PasswordRequirements.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("passwordRequirements") + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("passwordRequirements") + } + return err + } + } + + return nil +} + +// ContextValidate validate this configuration based on the context it is used func (m *Configuration) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := m.contextValidatePasswordRequirements(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *Configuration) contextValidatePasswordRequirements(ctx context.Context, formats strfmt.Registry) error { + + if m.PasswordRequirements != nil { + if err := m.PasswordRequirements.ContextValidate(ctx, formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("passwordRequirements") + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("passwordRequirements") + } + return err + } + } + return nil } diff --git a/rest_model_zrok/password_requirements.go b/rest_model_zrok/password_requirements.go new file mode 100644 index 00000000..2672ef78 --- /dev/null +++ b/rest_model_zrok/password_requirements.go @@ -0,0 +1,62 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package rest_model_zrok + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// PasswordRequirements password requirements +// +// swagger:model passwordRequirements +type PasswordRequirements struct { + + // length + Length int64 `json:"length,omitempty"` + + // require capital + RequireCapital bool `json:"requireCapital,omitempty"` + + // require numeric + RequireNumeric bool `json:"requireNumeric,omitempty"` + + // require special + RequireSpecial bool `json:"requireSpecial,omitempty"` + + // valid special characters + ValidSpecialCharacters string `json:"validSpecialCharacters,omitempty"` +} + +// Validate validates this password requirements +func (m *PasswordRequirements) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this password requirements based on context it is used +func (m *PasswordRequirements) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PasswordRequirements) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PasswordRequirements) UnmarshalBinary(b []byte) error { + var res PasswordRequirements + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/rest_server_zrok/embedded_spec.go b/rest_server_zrok/embedded_spec.go index 78bdfeee..05879de2 100644 --- a/rest_server_zrok/embedded_spec.go +++ b/rest_server_zrok/embedded_spec.go @@ -754,6 +754,12 @@ func init() { "404": { "description": "request not found" }, + "422": { + "description": "password validation failure", + "schema": { + "$ref": "#/definitions/errorMessage" + } + }, "500": { "description": "internal server error" } @@ -782,6 +788,12 @@ func init() { "404": { "description": "request not found" }, + "422": { + "description": "password validation failure", + "schema": { + "$ref": "#/definitions/errorMessage" + } + }, "500": { "description": "internal server error" } @@ -1062,6 +1074,9 @@ func init() { "invitesOpen": { "type": "boolean" }, + "passwordRequirements": { + "$ref": "#/definitions/passwordRequirements" + }, "requiresInviteToken": { "type": "boolean" }, @@ -1295,6 +1310,26 @@ func init() { } } }, + "passwordRequirements": { + "type": "object", + "properties": { + "length": { + "type": "integer" + }, + "requireCapital": { + "type": "boolean" + }, + "requireNumeric": { + "type": "boolean" + }, + "requireSpecial": { + "type": "boolean" + }, + "validSpecialCharacters": { + "type": "string" + } + } + }, "principal": { "type": "object", "properties": { @@ -2315,6 +2350,12 @@ func init() { "404": { "description": "request not found" }, + "422": { + "description": "password validation failure", + "schema": { + "$ref": "#/definitions/errorMessage" + } + }, "500": { "description": "internal server error" } @@ -2343,6 +2384,12 @@ func init() { "404": { "description": "request not found" }, + "422": { + "description": "password validation failure", + "schema": { + "$ref": "#/definitions/errorMessage" + } + }, "500": { "description": "internal server error" } @@ -2623,6 +2670,9 @@ func init() { "invitesOpen": { "type": "boolean" }, + "passwordRequirements": { + "$ref": "#/definitions/passwordRequirements" + }, "requiresInviteToken": { "type": "boolean" }, @@ -2856,6 +2906,26 @@ func init() { } } }, + "passwordRequirements": { + "type": "object", + "properties": { + "length": { + "type": "integer" + }, + "requireCapital": { + "type": "boolean" + }, + "requireNumeric": { + "type": "boolean" + }, + "requireSpecial": { + "type": "boolean" + }, + "validSpecialCharacters": { + "type": "string" + } + } + }, "principal": { "type": "object", "properties": { diff --git a/rest_server_zrok/operations/account/register_responses.go b/rest_server_zrok/operations/account/register_responses.go index 48c8af53..38bbe8b0 100644 --- a/rest_server_zrok/operations/account/register_responses.go +++ b/rest_server_zrok/operations/account/register_responses.go @@ -83,6 +83,49 @@ func (o *RegisterNotFound) WriteResponse(rw http.ResponseWriter, producer runtim rw.WriteHeader(404) } +// RegisterUnprocessableEntityCode is the HTTP code returned for type RegisterUnprocessableEntity +const RegisterUnprocessableEntityCode int = 422 + +/* +RegisterUnprocessableEntity password validation failure + +swagger:response registerUnprocessableEntity +*/ +type RegisterUnprocessableEntity struct { + + /* + In: Body + */ + Payload rest_model_zrok.ErrorMessage `json:"body,omitempty"` +} + +// NewRegisterUnprocessableEntity creates RegisterUnprocessableEntity with default headers values +func NewRegisterUnprocessableEntity() *RegisterUnprocessableEntity { + + return &RegisterUnprocessableEntity{} +} + +// WithPayload adds the payload to the register unprocessable entity response +func (o *RegisterUnprocessableEntity) WithPayload(payload rest_model_zrok.ErrorMessage) *RegisterUnprocessableEntity { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the register unprocessable entity response +func (o *RegisterUnprocessableEntity) SetPayload(payload rest_model_zrok.ErrorMessage) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *RegisterUnprocessableEntity) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(422) + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } +} + // RegisterInternalServerErrorCode is the HTTP code returned for type RegisterInternalServerError const RegisterInternalServerErrorCode int = 500 diff --git a/rest_server_zrok/operations/account/reset_password_responses.go b/rest_server_zrok/operations/account/reset_password_responses.go index a23e4181..4a813b30 100644 --- a/rest_server_zrok/operations/account/reset_password_responses.go +++ b/rest_server_zrok/operations/account/reset_password_responses.go @@ -9,6 +9,8 @@ import ( "net/http" "github.com/go-openapi/runtime" + + "github.com/openziti/zrok/rest_model_zrok" ) // ResetPasswordOKCode is the HTTP code returned for type ResetPasswordOK @@ -61,6 +63,49 @@ func (o *ResetPasswordNotFound) WriteResponse(rw http.ResponseWriter, producer r rw.WriteHeader(404) } +// ResetPasswordUnprocessableEntityCode is the HTTP code returned for type ResetPasswordUnprocessableEntity +const ResetPasswordUnprocessableEntityCode int = 422 + +/* +ResetPasswordUnprocessableEntity password validation failure + +swagger:response resetPasswordUnprocessableEntity +*/ +type ResetPasswordUnprocessableEntity struct { + + /* + In: Body + */ + Payload rest_model_zrok.ErrorMessage `json:"body,omitempty"` +} + +// NewResetPasswordUnprocessableEntity creates ResetPasswordUnprocessableEntity with default headers values +func NewResetPasswordUnprocessableEntity() *ResetPasswordUnprocessableEntity { + + return &ResetPasswordUnprocessableEntity{} +} + +// WithPayload adds the payload to the reset password unprocessable entity response +func (o *ResetPasswordUnprocessableEntity) WithPayload(payload rest_model_zrok.ErrorMessage) *ResetPasswordUnprocessableEntity { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the reset password unprocessable entity response +func (o *ResetPasswordUnprocessableEntity) SetPayload(payload rest_model_zrok.ErrorMessage) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *ResetPasswordUnprocessableEntity) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(422) + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } +} + // ResetPasswordInternalServerErrorCode is the HTTP code returned for type ResetPasswordInternalServerError const ResetPasswordInternalServerErrorCode int = 500 diff --git a/specs/zrok.yml b/specs/zrok.yml index cf43aa67..c71462ab 100644 --- a/specs/zrok.yml +++ b/specs/zrok.yml @@ -72,6 +72,10 @@ paths: $ref: "#/definitions/registerResponse" 404: description: request not found + 422: + description: password validation failure + schema: + $ref: '#/definitions/errorMessage' 500: description: internal server error @@ -90,6 +94,10 @@ paths: description: password reset 404: description: request not found + 422: + description: password validation failure + schema: + $ref: '#/definitions/errorMessage' 500: description: internal server error @@ -678,6 +686,8 @@ definitions: type: boolean inviteTokenContact: type: string + passwordRequirements: + $ref: "#/definitions/passwordRequirements" createFrontendRequest: type: object @@ -840,6 +850,20 @@ definitions: type: array items: $ref: "#/definitions/environmentAndResources" + + passwordRequirements: + type: object + properties: + length: + type: integer + requireCapital: + type: boolean + requireNumeric: + type: boolean + requireSpecial: + type: boolean + validSpecialCharacters: + type: string principal: type: object diff --git a/ui/src/api/types.js b/ui/src/api/types.js index 0f00f787..c4e6b95b 100644 --- a/ui/src/api/types.js +++ b/ui/src/api/types.js @@ -34,6 +34,7 @@ * @property {boolean} invitesOpen * @property {boolean} requiresInviteToken * @property {string} inviteTokenContact + * @property {module:types.passwordRequirements} passwordRequirements */ /** @@ -166,6 +167,17 @@ * @property {module:types.environmentAndResources[]} environments */ +/** + * @typedef passwordRequirements + * @memberof module:types + * + * @property {number} length + * @property {boolean} requireCapital + * @property {boolean} requireNumeric + * @property {boolean} requireSpecial + * @property {string} validSpecialCharacters + */ + /** * @typedef principal * @memberof module:types diff --git a/ui/src/components/password.js b/ui/src/components/password.js new file mode 100644 index 00000000..836701f1 --- /dev/null +++ b/ui/src/components/password.js @@ -0,0 +1,99 @@ +import React, {useEffect, useState, Fragment} from "react"; +import {Button, Container, Form, Row} from "react-bootstrap"; + +const PasswordForm = (props) => { + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + + const passwordMismatchMessage =

Entered passwords do not match!

+ const passwordTooShortMessage =

Entered password too short! ({props.passwordLength} characters, minimum)

+ const passwordRequiresCapitalMessage =

Entered password requires a capital letter!

+ const passwordRequiresNumericMessage =

Entered password requires a digit!

+ const passwordRequiresSpecialMessage =

Entered password requires a special character! ({props.passwordValidSpecialCharacters.split("").join(" ")})

+ + useEffect(() => { + if (confirm === "" && password === "") { + return + } + if (confirm.length < props.passwordLength) { + props.setMessage(passwordTooShortMessage) + return; + } + if (props.passwordRequireCapital && !/[A-Z]/.test(confirm)) { + props.setMessage(passwordRequiresCapitalMessage) + return; + } + if (props.passwordRequireNumeric && !/\d/.test(confirm)) { + props.setMessage(passwordRequiresNumericMessage) + return; + } + if (props.passwordRequireSpecial) { + if (!props.passwordValidSpecialCharacters.split("").some(v => confirm.includes(v))) { + props.setMessage(passwordRequiresSpecialMessage) + return; + } + } + if (confirm != password) { + props.setMessage(passwordMismatchMessage) + return; + } + props.setParentPassword(password) + }, [password, confirm]) + + return ( + + { + (props.passwordLength > 0 || props.passwordRequireCapital || props.passwordRequireNumeric || props.passwordRequireSpecial) && +

Password Requirements

+ } + { + props.passwordLength > 0 && + Minimum Length of {props.passwordLength} + } + { + props.passwordRequireCapital && + Requires at least 1 Capital Letter + } + { + props.passwordRequireNumeric && + Requires at least 1 Digit + } + { + props.passwordRequireSpecial && + + Requires at least 1 Special Character + {props.passwordValidSpecialCharacters.split("").join(" ")} + + } + + + { props.setMessage(null); setPassword(t.target.value);}} + value={password} + /> + + + + { props.setMessage(null); setConfirm(t.target.value);}} + value={confirm} + /> + + +
+ ) +}; + +PasswordForm.defaultProps = { + passwordLength: 0, + passwordRequireCapital: false, + passwordRequireNumeric: false, + passwordRequireSpecial: false, + passwordValidSpecialCharacters: "" +} + +export default PasswordForm; \ No newline at end of file diff --git a/ui/src/register/SetPasswordForm.js b/ui/src/register/SetPasswordForm.js index 71ad43dd..63d427db 100644 --- a/ui/src/register/SetPasswordForm.js +++ b/ui/src/register/SetPasswordForm.js @@ -3,26 +3,33 @@ import * as account from "../api/account"; import * as metadata from "../api/metadata" import Success from "./Success"; import {Button, Container, Form, Row} from "react-bootstrap"; +import PasswordForm from "../components/password"; const SetPasswordForm = (props) => { const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); const [message, setMessage] = useState(); const [authToken, setAuthToken] = useState(''); const [complete, setComplete] = useState(false); const [tou, setTou] = useState(); + const [passwordLength, setPasswordLength] = useState(10); + const [passwordRequireCapital, setPasswordRequireCapital] = useState(true); + const [passwordRequireNumeric, setPasswordRequireNumeric] = useState(true); + const [passwordRequireSpecial, setPasswordRequireSpecial] = useState(true); + const [passwordValidSpecialCharacters, setPasswordValidSpecialCharacters] = useState(""); - const passwordMismatchMessage =

Entered passwords do not match!

- const passwordTooShortMessage =

Entered password too short! (4 characters, minimum)

const registerFailed =

Account creation failed!

useEffect(() => { metadata.configuration().then(resp => { - console.log(resp) if(!resp.error) { if (resp.data.touLink !== null && resp.data.touLink.trim() !== "") { setTou(resp.data.touLink) } + setPasswordLength(resp.data.passwordRequirements.length) + setPasswordRequireCapital(resp.data.passwordRequirements.requireCapital) + setPasswordRequireNumeric(resp.data.passwordRequirements.requireNumeric) + setPasswordRequireSpecial(resp.data.passwordRequirements.requireSpecial) + setPasswordValidSpecialCharacters(resp.data.passwordRequirements.validSpecialCharacters) } }).catch(err => { console.log("err", err); @@ -31,14 +38,7 @@ const SetPasswordForm = (props) => { const handleSubmit = async e => { e.preventDefault(); - if(confirm.length < 4) { - setMessage(passwordTooShortMessage); - return; - } - if(confirm !== password) { - setMessage(passwordMismatchMessage); - return; - } + if (password !== undefined && password !== "") { account.register({body: {"token": props.token, "password": password}}) .then(resp => { if(!resp.error) { @@ -54,6 +54,7 @@ const SetPasswordForm = (props) => { console.log("resp", resp); setMessage(registerFailed); }); + } }; if(!complete) { @@ -72,23 +73,14 @@ const SetPasswordForm = (props) => {
- - { setMessage(null); setPassword(t.target.value); }} - value={password} - /> - - - - { setMessage(null); setConfirm(t.target.value); }} - value={confirm} - /> - +
diff --git a/ui/src/resetPassword/SetNewPassword.js b/ui/src/resetPassword/SetNewPassword.js index 00efb681..97e6f345 100644 --- a/ui/src/resetPassword/SetNewPassword.js +++ b/ui/src/resetPassword/SetNewPassword.js @@ -1,29 +1,39 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import * as account from '../api/account'; +import * as metadata from "../api/metadata" import {Button, Container, Form, Row} from "react-bootstrap"; import { Link } from "react-router-dom"; +import PasswordForm from "../components/password"; const SetNewPassword = (props) => { const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); const [message, setMessage] = useState(); const [complete, setComplete] = useState(false); - - const passwordMismatchMessage =

Entered passwords do not match!

- const passwordTooShortMessage =

Entered password too short! (4 characters, minimum)

+ const [passwordLength, setPasswordLength] = useState(10); + const [passwordRequireCapital, setPasswordRequireCapital] = useState(true); + const [passwordRequireNumeric, setPasswordRequireNumeric] = useState(true); + const [passwordRequireSpecial, setPasswordRequireSpecial] = useState(true); + const [passwordValidSpecialCharacters, setPasswordValidSpecialCharacters] = useState(""); const errorMessage =

Reset Password Failed!

; + useEffect(() => { + metadata.configuration().then(resp => { + if(!resp.error) { + setPasswordLength(resp.data.passwordRequirements.length) + setPasswordRequireCapital(resp.data.passwordRequirements.requireCapital) + setPasswordRequireNumeric(resp.data.passwordRequirements.requireNumeric) + setPasswordRequireSpecial(resp.data.passwordRequirements.requireSpecial) + setPasswordValidSpecialCharacters(resp.data.passwordRequirements.validSpecialCharacters) + } + }).catch(err => { + console.log("err", err); + }); + }, []) + const handleSubmit = async e => { e.preventDefault(); - if(confirm.length < 4) { - setMessage(passwordTooShortMessage); - return; - } - if(confirm !== password) { - setMessage(passwordMismatchMessage); - return; - } + if (password !== undefined && password !== "") { account.resetPassword({body: {"token": props.token, "password": password}}) .then(resp => { if(!resp.error) { @@ -36,6 +46,7 @@ const SetNewPassword = (props) => { .catch(resp => { setMessage(errorMessage); }) + } } if(!complete) { @@ -51,23 +62,14 @@ const SetNewPassword = (props) => {
- - { setMessage(null); setPassword(t.target.value); }} - value={password} - /> - - - - { setMessage(null); setConfirm(t.target.value); }} - value={confirm} - /> - +