mirror of https://github.com/portainer/portainer
feat(internal-auth): ability to set minimum password length [EE-3175] (#6942)
* feat(internal-auth): ability to set minimum password length [EE-3175] * pass props to react component * fixes + WIP slider * fix slider updating + add styles * remove nested ternary * fix slider updating + add remind me later button * add length to settings + value & onchange method * finish my account view * fix slider updating * slider styles * update style * move slider in * update size of slider * allow admin to browse to authentication view * use feather icons instead of font awesome * feat(settings): add colors to password rules * clean up tooltip styles * more style changes * styles * fixes + use requiredLength in password field for icon logic * simplify logic * simplify slider logic and remove debug code * use required length for logic to display pwd length warning * fix slider styles * use requiredPasswordLength to determine if password is valid * style tooltip based on theme * reset skips when password is changed * misc cleanup * reset skips when required length is changed * fix formatting * fix issues * implement some suggestions * simplify logic * update broken test * pick min password length from DB * fix suggestions * set up min password length in the DB * fix test after migration * fix formatting issue * fix bug with icon * refactored migration * fix typo * fixes * fix logic * set skips per user * reset skips for all users on length change Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com> Co-authored-by: Dmitry Salakhov <to@dimasalakhov.com>pull/6973/head^2
parent
4195d93a16
commit
bca1c6b9cf
|
@ -10,7 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||||
passphrase = "my secret key"
|
passphrase = "my secret key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,9 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
||||||
EnableTelemetry: true,
|
EnableTelemetry: true,
|
||||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||||
BlackListedLabels: make([]portainer.Pair, 0),
|
BlackListedLabels: make([]portainer.Pair, 0),
|
||||||
|
InternalAuthSettings: portainer.InternalAuthSettings{
|
||||||
|
RequiredPasswordLength: 12,
|
||||||
|
},
|
||||||
LDAPSettings: portainer.LDAPSettings{
|
LDAPSettings: portainer.LDAPSettings{
|
||||||
AnonymousMode: true,
|
AnonymousMode: true,
|
||||||
AutoCreateUsers: true,
|
AutoCreateUsers: true,
|
||||||
|
|
|
@ -100,6 +100,9 @@ func (m *Migrator) Migrate() error {
|
||||||
|
|
||||||
// Portainer 2.13
|
// Portainer 2.13
|
||||||
newMigration(40, m.migrateDBVersionToDB40),
|
newMigration(40, m.migrateDBVersionToDB40),
|
||||||
|
|
||||||
|
// Portainer 2.14
|
||||||
|
newMigration(50, m.migrateDBVersionToDB50),
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastDbVersion int
|
var lastDbVersion int
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package migrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Migrator) migrateDBVersionToDB50() error {
|
||||||
|
return m.migratePasswordLengthSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) migratePasswordLengthSettings() error {
|
||||||
|
migrateLog.Info("Updating required password length")
|
||||||
|
s, err := m.settingsService.Settings()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unable to retrieve settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InternalAuthSettings.RequiredPasswordLength = 12
|
||||||
|
return m.settingsService.UpdateSettings(s)
|
||||||
|
}
|
|
@ -690,6 +690,9 @@
|
||||||
"EnforceEdgeID": false,
|
"EnforceEdgeID": false,
|
||||||
"FeatureFlagSettings": null,
|
"FeatureFlagSettings": null,
|
||||||
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
||||||
|
"InternalAuthSettings": {
|
||||||
|
"RequiredPasswordLength": 12
|
||||||
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell",
|
"KubectlShellImage": "portainer/kubectl-shell",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
|
|
|
@ -13,7 +13,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type authenticatePayload struct {
|
type authenticatePayload struct {
|
||||||
|
@ -101,7 +100,7 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
|
||||||
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
|
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
|
||||||
}
|
}
|
||||||
|
|
||||||
forceChangePassword := !passwordutils.StrengthCheck(password)
|
forceChangePassword := !handler.passwordStrengthChecker.Check(password)
|
||||||
return handler.writeToken(w, user, forceChangePassword)
|
return handler.writeToken(w, user, forceChangePassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,12 +22,14 @@ type Handler struct {
|
||||||
OAuthService portainer.OAuthService
|
OAuthService portainer.OAuthService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||||
|
passwordStrengthChecker security.PasswordStrengthChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage authentication operations.
|
// NewHandler creates a handler to manage authentication operations.
|
||||||
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter) *Handler {
|
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
|
passwordStrengthChecker: passwordStrengthChecker,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Handle("/auth/oauth/validate",
|
h.Handle("/auth/oauth/validate",
|
||||||
|
|
|
@ -14,6 +14,8 @@ type publicSettingsResponse struct {
|
||||||
LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"`
|
LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"`
|
||||||
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
||||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
||||||
|
// The minimum required length for a password of any user when using internal auth mode
|
||||||
|
RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"`
|
||||||
// Whether edge compute features are enabled
|
// Whether edge compute features are enabled
|
||||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
|
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
|
||||||
// Supported feature flags
|
// Supported feature flags
|
||||||
|
@ -51,6 +53,7 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
|
||||||
publicSettings := &publicSettingsResponse{
|
publicSettings := &publicSettingsResponse{
|
||||||
LogoURL: appSettings.LogoURL,
|
LogoURL: appSettings.LogoURL,
|
||||||
AuthenticationMethod: appSettings.AuthenticationMethod,
|
AuthenticationMethod: appSettings.AuthenticationMethod,
|
||||||
|
RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength,
|
||||||
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
|
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
|
||||||
EnableTelemetry: appSettings.EnableTelemetry,
|
EnableTelemetry: appSettings.EnableTelemetry,
|
||||||
KubeconfigExpiry: appSettings.KubeconfigExpiry,
|
KubeconfigExpiry: appSettings.KubeconfigExpiry,
|
||||||
|
|
|
@ -22,9 +22,10 @@ type settingsUpdatePayload struct {
|
||||||
// A list of label name & value that will be used to hide containers when querying containers
|
// A list of label name & value that will be used to hide containers when querying containers
|
||||||
BlackListedLabels []portainer.Pair
|
BlackListedLabels []portainer.Pair
|
||||||
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
||||||
AuthenticationMethod *int `example:"1"`
|
AuthenticationMethod *int `example:"1"`
|
||||||
LDAPSettings *portainer.LDAPSettings `example:""`
|
InternalAuthSettings *portainer.InternalAuthSettings `example:""`
|
||||||
OAuthSettings *portainer.OAuthSettings `example:""`
|
LDAPSettings *portainer.LDAPSettings `example:""`
|
||||||
|
OAuthSettings *portainer.OAuthSettings `example:""`
|
||||||
// The interval in which environment(endpoint) snapshots are created
|
// The interval in which environment(endpoint) snapshots are created
|
||||||
SnapshotInterval *string `example:"5m"`
|
SnapshotInterval *string `example:"5m"`
|
||||||
// URL to the templates that will be displayed in the UI when navigating to App Templates
|
// URL to the templates that will be displayed in the UI when navigating to App Templates
|
||||||
|
@ -153,6 +154,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
settings.BlackListedLabels = payload.BlackListedLabels
|
settings.BlackListedLabels = payload.BlackListedLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payload.InternalAuthSettings != nil {
|
||||||
|
settings.InternalAuthSettings.RequiredPasswordLength = payload.InternalAuthSettings.RequiredPasswordLength
|
||||||
|
}
|
||||||
|
|
||||||
if payload.LDAPSettings != nil {
|
if payload.LDAPSettings != nil {
|
||||||
ldapReaderDN := settings.LDAPSettings.ReaderDN
|
ldapReaderDN := settings.LDAPSettings.ReaderDN
|
||||||
ldapPassword := settings.LDAPSettings.Password
|
ldapPassword := settings.LDAPSettings.Password
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminInitPayload struct {
|
type adminInitPayload struct {
|
||||||
|
@ -58,7 +57,7 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe
|
||||||
return &httperror.HandlerError{http.StatusConflict, "Unable to create administrator user", errAdminAlreadyInitialized}
|
return &httperror.HandlerError{http.StatusConflict, "Unable to create administrator user", errAdminAlreadyInitialized}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !passwordutils.StrengthCheck(payload.Password) {
|
if !handler.passwordStrengthChecker.Check(payload.Password) {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
|
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,20 +31,22 @@ func hideFields(user *portainer.User) {
|
||||||
// Handler is the HTTP handler used to handle user operations.
|
// Handler is the HTTP handler used to handle user operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
bouncer *security.RequestBouncer
|
bouncer *security.RequestBouncer
|
||||||
apiKeyService apikey.APIKeyService
|
apiKeyService apikey.APIKeyService
|
||||||
demoService *demo.Service
|
demoService *demo.Service
|
||||||
DataStore dataservices.DataStore
|
DataStore dataservices.DataStore
|
||||||
CryptoService portainer.CryptoService
|
CryptoService portainer.CryptoService
|
||||||
|
passwordStrengthChecker security.PasswordStrengthChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage user operations.
|
// NewHandler creates a handler to manage user operations.
|
||||||
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService, demoService *demo.Service) *Handler {
|
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService, demoService *demo.Service, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
bouncer: bouncer,
|
bouncer: bouncer,
|
||||||
apiKeyService: apiKeyService,
|
apiKeyService: apiKeyService,
|
||||||
demoService: demoService,
|
demoService: demoService,
|
||||||
|
passwordStrengthChecker: passwordStrengthChecker,
|
||||||
}
|
}
|
||||||
h.Handle("/users",
|
h.Handle("/users",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost)
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type userCreatePayload struct {
|
type userCreatePayload struct {
|
||||||
|
@ -95,7 +94,7 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
||||||
if !passwordutils.StrengthCheck(payload.Password) {
|
if !handler.passwordStrengthChecker.Check(payload.Password) {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
|
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,8 +39,9 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
|
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
|
||||||
|
|
||||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil)
|
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||||
h.DataStore = store
|
h.DataStore = store
|
||||||
|
|
||||||
// generate standard and admin user tokens
|
// generate standard and admin user tokens
|
||||||
|
|
|
@ -31,8 +31,9 @@ func Test_deleteUserRemovesAccessTokens(t *testing.T) {
|
||||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
|
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
|
||||||
|
|
||||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil)
|
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||||
h.DataStore = store
|
h.DataStore = store
|
||||||
|
|
||||||
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {
|
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {
|
||||||
|
|
|
@ -38,8 +38,9 @@ func Test_userGetAccessTokens(t *testing.T) {
|
||||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
|
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
|
||||||
|
|
||||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil)
|
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||||
h.DataStore = store
|
h.DataStore = store
|
||||||
|
|
||||||
// generate standard and admin user tokens
|
// generate standard and admin user tokens
|
||||||
|
|
|
@ -36,8 +36,9 @@ func Test_userRemoveAccessToken(t *testing.T) {
|
||||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
|
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
|
||||||
|
|
||||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil)
|
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||||
h.DataStore = store
|
h.DataStore = store
|
||||||
|
|
||||||
// generate standard and admin user tokens
|
// generate standard and admin user tokens
|
||||||
|
|
|
@ -12,7 +12,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type userUpdatePasswordPayload struct {
|
type userUpdatePasswordPayload struct {
|
||||||
|
@ -86,7 +85,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||||
return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized}
|
return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !passwordutils.StrengthCheck(payload.NewPassword) {
|
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
|
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,8 +31,9 @@ func Test_updateUserRemovesAccessTokens(t *testing.T) {
|
||||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
|
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
|
||||||
|
|
||||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil)
|
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||||
h.DataStore = store
|
h.DataStore = store
|
||||||
|
|
||||||
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {
|
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordStrengthChecker interface {
|
||||||
|
Check(password string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordStrengthChecker struct {
|
||||||
|
settings settingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasswordStrengthChecker(settings settingsService) *passwordStrengthChecker {
|
||||||
|
return &passwordStrengthChecker{
|
||||||
|
settings: settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check returns true if the password is strong enough
|
||||||
|
func (c *passwordStrengthChecker) Check(password string) bool {
|
||||||
|
s, err := c.settings.Settings()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Warn("failed to fetch Portainer settings to validate user password")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(password) >= s.InternalAuthSettings.RequiredPasswordLength
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsService interface {
|
||||||
|
Settings() (*portainer.Settings, error)
|
||||||
|
}
|
|
@ -1,8 +1,14 @@
|
||||||
package passwordutils
|
package security
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
func TestStrengthCheck(t *testing.T) {
|
func TestStrengthCheck(t *testing.T) {
|
||||||
|
checker := NewPasswordStrengthChecker(settingsStub{minLength: 12})
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
password string
|
password string
|
||||||
}
|
}
|
||||||
|
@ -23,9 +29,21 @@ func TestStrengthCheck(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if gotStrong := StrengthCheck(tt.args.password); gotStrong != tt.wantStrong {
|
if gotStrong := checker.Check(tt.args.password); gotStrong != tt.wantStrong {
|
||||||
t.Errorf("StrengthCheck() = %v, want %v", gotStrong, tt.wantStrong)
|
t.Errorf("StrengthCheck() = %v, want %v", gotStrong, tt.wantStrong)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type settingsStub struct {
|
||||||
|
minLength int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s settingsStub) Settings() (*portainer.Settings, error) {
|
||||||
|
return &portainer.Settings{
|
||||||
|
InternalAuthSettings: portainer.InternalAuthSettings{
|
||||||
|
RequiredPasswordLength: s.minLength,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
|
@ -111,7 +111,9 @@ func (server *Server) Start() error {
|
||||||
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
|
||||||
offlineGate := offlinegate.NewOfflineGate()
|
offlineGate := offlinegate.NewOfflineGate()
|
||||||
|
|
||||||
var authHandler = auth.NewHandler(requestBouncer, rateLimiter)
|
passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings())
|
||||||
|
|
||||||
|
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker)
|
||||||
authHandler.DataStore = server.DataStore
|
authHandler.DataStore = server.DataStore
|
||||||
authHandler.CryptoService = server.CryptoService
|
authHandler.CryptoService = server.CryptoService
|
||||||
authHandler.JWTService = server.JWTService
|
authHandler.JWTService = server.JWTService
|
||||||
|
@ -254,7 +256,7 @@ func (server *Server) Start() error {
|
||||||
var uploadHandler = upload.NewHandler(requestBouncer)
|
var uploadHandler = upload.NewHandler(requestBouncer)
|
||||||
uploadHandler.FileService = server.FileService
|
uploadHandler.FileService = server.FileService
|
||||||
|
|
||||||
var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService, server.DemoService)
|
var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService, server.DemoService, passwordStrengthChecker)
|
||||||
userHandler.DataStore = server.DataStore
|
userHandler.DataStore = server.DataStore
|
||||||
userHandler.CryptoService = server.CryptoService
|
userHandler.CryptoService = server.CryptoService
|
||||||
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
package passwordutils
|
|
||||||
|
|
||||||
const MinPasswordLen = 12
|
|
||||||
|
|
||||||
func lengthCheck(password string) bool {
|
|
||||||
return len(password) >= MinPasswordLen
|
|
||||||
}
|
|
||||||
|
|
||||||
func StrengthCheck(password string) bool {
|
|
||||||
return lengthCheck(password)
|
|
||||||
}
|
|
|
@ -549,6 +549,11 @@ type (
|
||||||
ShellExecCommand string
|
ShellExecCommand string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InternalAuthSettings represents settings used for the default 'internal' authentication
|
||||||
|
InternalAuthSettings struct {
|
||||||
|
RequiredPasswordLength int
|
||||||
|
}
|
||||||
|
|
||||||
// LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server
|
// LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server
|
||||||
LDAPGroupSearchSettings struct {
|
LDAPGroupSearchSettings struct {
|
||||||
// The distinguished name of the element from which the LDAP server will search for groups
|
// The distinguished name of the element from which the LDAP server will search for groups
|
||||||
|
@ -799,6 +804,7 @@ type (
|
||||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||||
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
||||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
||||||
|
InternalAuthSettings InternalAuthSettings `json:"InternalAuthSettings" example:""`
|
||||||
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
|
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
|
||||||
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
|
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
|
||||||
OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
|
OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
|
||||||
|
|
3842
api/swagger.yaml
3842
api/swagger.yaml
File diff suppressed because it is too large
Load Diff
|
@ -1,42 +1,21 @@
|
||||||
import { react2angular } from '@/react-tools/react2angular';
|
import { react2angular } from '@/react-tools/react2angular';
|
||||||
|
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||||
import { MinPasswordLen } from '../helpers/password';
|
|
||||||
|
|
||||||
export function ForcePasswordUpdateHint() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
<i
|
|
||||||
className="fa fa-exclamation-triangle orange-icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<b> Please update your password to continue </b>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted">
|
|
||||||
The password must be at least {MinPasswordLen} characters long.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasswordCheckHint() {
|
export function PasswordCheckHint() {
|
||||||
|
const settingsQuery = usePublicSettings();
|
||||||
|
const minPasswordLength = settingsQuery.data?.RequiredPasswordLength;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
<i className="fa fa-times red-icon space-right" aria-hidden="true">
|
<i
|
||||||
{' '}
|
className="fa fa-exclamation-triangle orange-icon space-right"
|
||||||
</i>
|
aria-hidden="true"
|
||||||
<span>
|
/>
|
||||||
The password must be at least {MinPasswordLen} characters long.
|
The password must be at least {minPasswordLength} characters long.
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ForcePasswordUpdateHintAngular = react2angular(
|
|
||||||
ForcePasswordUpdateHint,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []);
|
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []);
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { ReactExampleAngular } from './ReactExample';
|
||||||
import { TooltipAngular } from './Tip/Tooltip';
|
import { TooltipAngular } from './Tip/Tooltip';
|
||||||
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
|
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
|
||||||
import { InformationPanelAngular } from './InformationPanel';
|
import { InformationPanelAngular } from './InformationPanel';
|
||||||
import { ForcePasswordUpdateHintAngular, PasswordCheckHintAngular } from './PasswordCheckHint';
|
import { PasswordCheckHintAngular } from './PasswordCheckHint';
|
||||||
import { ViewLoadingAngular } from './ViewLoading';
|
import { ViewLoadingAngular } from './ViewLoading';
|
||||||
|
|
||||||
export default angular
|
export default angular
|
||||||
|
@ -24,6 +24,5 @@ export default angular
|
||||||
.component('portainerTooltip', TooltipAngular)
|
.component('portainerTooltip', TooltipAngular)
|
||||||
.component('reactExample', ReactExampleAngular)
|
.component('reactExample', ReactExampleAngular)
|
||||||
.component('beFeatureIndicator', beFeatureIndicatorAngular)
|
.component('beFeatureIndicator', beFeatureIndicatorAngular)
|
||||||
.component('forcePasswordUpdateHint', ForcePasswordUpdateHintAngular)
|
|
||||||
.component('passwordCheckHint', PasswordCheckHintAngular)
|
.component('passwordCheckHint', PasswordCheckHintAngular)
|
||||||
.component('createAccessToken', CreateAccessTokenAngular).name;
|
.component('createAccessToken', CreateAccessTokenAngular).name;
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
export const MinPasswordLen = 12;
|
|
||||||
|
|
||||||
function lengthCheck(password: string) {
|
|
||||||
return password.length >= MinPasswordLen;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StrengthCheck(password: string) {
|
|
||||||
return lengthCheck(password);
|
|
||||||
}
|
|
|
@ -2,6 +2,7 @@ export function SettingsViewModel(data) {
|
||||||
this.LogoURL = data.LogoURL;
|
this.LogoURL = data.LogoURL;
|
||||||
this.BlackListedLabels = data.BlackListedLabels;
|
this.BlackListedLabels = data.BlackListedLabels;
|
||||||
this.AuthenticationMethod = data.AuthenticationMethod;
|
this.AuthenticationMethod = data.AuthenticationMethod;
|
||||||
|
this.InternalAuthSettings = data.InternalAuthSettings;
|
||||||
this.LDAPSettings = data.LDAPSettings;
|
this.LDAPSettings = data.LDAPSettings;
|
||||||
this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings);
|
this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings);
|
||||||
this.openAMTConfiguration = data.openAMTConfiguration;
|
this.openAMTConfiguration = data.openAMTConfiguration;
|
||||||
|
@ -23,6 +24,7 @@ export function SettingsViewModel(data) {
|
||||||
|
|
||||||
export function PublicSettingsViewModel(settings) {
|
export function PublicSettingsViewModel(settings) {
|
||||||
this.AuthenticationMethod = settings.AuthenticationMethod;
|
this.AuthenticationMethod = settings.AuthenticationMethod;
|
||||||
|
this.RequiredPasswordLength = settings.RequiredPasswordLength;
|
||||||
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||||
this.EnforceEdgeID = settings.EnforceEdgeID;
|
this.EnforceEdgeID = settings.EnforceEdgeID;
|
||||||
this.FeatureFlagSettings = settings.FeatureFlagSettings;
|
this.FeatureFlagSettings = settings.FeatureFlagSettings;
|
||||||
|
@ -33,6 +35,10 @@ export function PublicSettingsViewModel(settings) {
|
||||||
this.KubeconfigExpiry = settings.KubeconfigExpiry;
|
this.KubeconfigExpiry = settings.KubeconfigExpiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InternalAuthSettingsViewModel(data) {
|
||||||
|
this.RequiredPasswordLength = data.RequiredPasswordLength;
|
||||||
|
}
|
||||||
|
|
||||||
export function LDAPSettingsViewModel(data) {
|
export function LDAPSettingsViewModel(data) {
|
||||||
this.ReaderDN = data.ReaderDN;
|
this.ReaderDN = data.ReaderDN;
|
||||||
this.Password = data.Password;
|
this.Password = data.Password;
|
||||||
|
|
|
@ -43,6 +43,27 @@ function StateManagerFactory(
|
||||||
LocalStorage.storeUIState(state.UI);
|
LocalStorage.storeUIState(state.UI);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
manager.setRequiredPasswordLength = function (length) {
|
||||||
|
state.UI.requiredPasswordLength = length;
|
||||||
|
LocalStorage.storeUIState(state.UI);
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.setPasswordChangeSkipped = function (userID) {
|
||||||
|
state.UI.timesPasswordChangeSkipped = state.UI.timesPasswordChangeSkipped || {};
|
||||||
|
state.UI.timesPasswordChangeSkipped[userID] = state.UI.timesPasswordChangeSkipped[userID] + 1 || 1;
|
||||||
|
LocalStorage.storeUIState(state.UI);
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.resetPasswordChangeSkips = function (userID) {
|
||||||
|
if (state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[userID]) state.UI.timesPasswordChangeSkipped[userID] = 0;
|
||||||
|
LocalStorage.storeUIState(state.UI);
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.clearPasswordChangeSkips = function () {
|
||||||
|
state.UI.timesPasswordChangeSkipped = {};
|
||||||
|
LocalStorage.storeUIState(state.UI);
|
||||||
|
};
|
||||||
|
|
||||||
manager.getState = function () {
|
manager.getState = function () {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||||
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
onSubmit(): void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveAuthSettingsButton({ onSubmit, isLoading }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSectionTitle>Actions</FormSectionTitle>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<LoadingButton
|
||||||
|
loadingText="Saving..."
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={() => onSubmit()}
|
||||||
|
>
|
||||||
|
Save settings
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,10 +2,10 @@ import angular from 'angular';
|
||||||
import ldapModule from './ldap';
|
import ldapModule from './ldap';
|
||||||
import { autoUserProvisionToggle } from './auto-user-provision-toggle';
|
import { autoUserProvisionToggle } from './auto-user-provision-toggle';
|
||||||
import { saveAuthSettingsButton } from './save-auth-settings-button';
|
import { saveAuthSettingsButton } from './save-auth-settings-button';
|
||||||
import { internalAuth } from './internal-auth';
|
import { InternalAuthAngular } from './internal-auth';
|
||||||
|
|
||||||
export default angular
|
export default angular
|
||||||
.module('portainer.settings.authentication', [ldapModule])
|
.module('portainer.settings.authentication', [ldapModule])
|
||||||
.component('internalAuth', internalAuth)
|
.component('internalAuth', InternalAuthAngular)
|
||||||
.component('saveAuthSettingsButton', saveAuthSettingsButton)
|
.component('saveAuthSettingsButton', saveAuthSettingsButton)
|
||||||
.component('autoUserProvisionToggle', autoUserProvisionToggle).name;
|
.component('autoUserProvisionToggle', autoUserProvisionToggle).name;
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
|
import { react2angular } from '@/react-tools/react2angular';
|
||||||
|
|
||||||
|
import { SaveAuthSettingsButton } from '../components/SaveAuthSettingsButton';
|
||||||
|
import { Settings } from '../../types';
|
||||||
|
|
||||||
|
import { PasswordLengthSlider } from './components/PasswordLengthSlider/PasswordLengthSlider';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
onSaveSettings(): void;
|
||||||
|
isLoading: boolean;
|
||||||
|
value: Settings['InternalAuthSettings'];
|
||||||
|
onChange(value: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InternalAuth({
|
||||||
|
onSaveSettings,
|
||||||
|
isLoading,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSectionTitle>Information</FormSectionTitle>
|
||||||
|
<div className="form-group col-sm-12 text-muted small">
|
||||||
|
When using internal authentication, Portainer will encrypt user
|
||||||
|
passwords and store credentials locally.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormSectionTitle>Password rules</FormSectionTitle>
|
||||||
|
<div className="form-group col-sm-12 text-muted small">
|
||||||
|
Define minimum length for user-generated passwords.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<PasswordLengthSlider
|
||||||
|
min={8}
|
||||||
|
max={18}
|
||||||
|
step={1}
|
||||||
|
value={value.RequiredPasswordLength}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SaveAuthSettingsButton onSubmit={onSaveSettings} isLoading={isLoading} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InternalAuthAngular = react2angular(InternalAuth, [
|
||||||
|
'onSaveSettings',
|
||||||
|
'isLoading',
|
||||||
|
'value',
|
||||||
|
'onChange',
|
||||||
|
]);
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
value: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function used as workaround to add opacity to the background color
|
||||||
|
function setOpacity(hex: string, alpha: number) {
|
||||||
|
return `${hex}${Math.floor(alpha * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ icon, value, color }: Props) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="badge inline-flex items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: setOpacity(color, 0.1),
|
||||||
|
color,
|
||||||
|
padding: '5px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
.slider-badge {
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
margin-left: 15px;
|
||||||
|
margin-top: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.rc-slider-tooltip-inner) {
|
||||||
|
height: auto;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: var(--bg-tooltip-color);
|
||||||
|
min-width: max-content;
|
||||||
|
color: var(--black);
|
||||||
|
box-shadow: var(--shadow-box-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.rc-slider-tooltip-arrow) {
|
||||||
|
border-top-color: var(--bg-tooltip-color);
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
import RcSlider from 'rc-slider';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Lock, XCircle, CheckCircle } from 'react-feather';
|
||||||
|
|
||||||
|
import { Badge } from '@/portainer/settings/authentication/internal-auth/components/Badge';
|
||||||
|
|
||||||
|
import 'rc-slider/assets/index.css';
|
||||||
|
|
||||||
|
import styles from './PasswordLengthSlider.module.css';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
value: number;
|
||||||
|
onChange(value: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Strength = 'weak' | 'good' | 'strong' | 'veryStrong';
|
||||||
|
|
||||||
|
const sliderProperties: Record<
|
||||||
|
Strength,
|
||||||
|
{ strength: string; color: string; text: string }
|
||||||
|
> = {
|
||||||
|
weak: {
|
||||||
|
strength: 'weak',
|
||||||
|
color: '#F04438',
|
||||||
|
text: 'Weak password',
|
||||||
|
},
|
||||||
|
good: {
|
||||||
|
strength: 'good',
|
||||||
|
color: '#F79009',
|
||||||
|
text: 'Good password',
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
strength: 'strong',
|
||||||
|
color: '#12B76A',
|
||||||
|
text: 'Strong password',
|
||||||
|
},
|
||||||
|
veryStrong: {
|
||||||
|
strength: 'veryStrong',
|
||||||
|
color: '#0BA5EC',
|
||||||
|
text: 'Very strong password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SliderWithTooltip = RcSlider.createSliderWithTooltip(RcSlider);
|
||||||
|
|
||||||
|
export function PasswordLengthSlider({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const sliderProps = getSliderProps(value);
|
||||||
|
|
||||||
|
function getSliderProps(value: number) {
|
||||||
|
if (value < 10) {
|
||||||
|
return sliderProperties.weak;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 12) {
|
||||||
|
return sliderProperties.good;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 14) {
|
||||||
|
return sliderProperties.strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliderProperties.veryStrong;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadgeIcon(strength: string) {
|
||||||
|
switch (strength) {
|
||||||
|
case 'weak':
|
||||||
|
return <XCircle size="13" className="space-right" strokeWidth="3px" />;
|
||||||
|
case 'good':
|
||||||
|
case 'strong':
|
||||||
|
return (
|
||||||
|
<CheckCircle size="13" className="space-right" strokeWidth="3px" />
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Lock size="13" className="space-right" strokeWidth="3px" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(sliderValue: number) {
|
||||||
|
onChange(sliderValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.root, styles[sliderProps.strength])}>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<SliderWithTooltip
|
||||||
|
tipFormatter={(value) => `${value} characters`}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
defaultValue={12}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
handleStyle={{
|
||||||
|
height: 25,
|
||||||
|
width: 25,
|
||||||
|
borderWidth: 1.85,
|
||||||
|
borderColor: sliderProps.color,
|
||||||
|
top: 1.5,
|
||||||
|
boxShadow: 'none',
|
||||||
|
}}
|
||||||
|
railStyle={{ height: 10 }}
|
||||||
|
trackStyle={{ backgroundColor: sliderProps.color, height: 10 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={clsx('col-sm-2', styles.sliderBadge)}>
|
||||||
|
<Badge
|
||||||
|
icon={getBadgeIcon(sliderProps.strength)}
|
||||||
|
value={sliderProps.text}
|
||||||
|
color={sliderProps.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
export const internalAuth = {
|
|
||||||
templateUrl: './internal-auth.html',
|
|
||||||
bindings: {
|
|
||||||
onSaveSettings: '<',
|
|
||||||
saveButtonState: '<',
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -0,0 +1 @@
|
||||||
|
export { InternalAuthAngular, InternalAuth } from './InternalAuth';
|
|
@ -1,4 +0,0 @@
|
||||||
<div class="col-sm-12 form-section-title"> Information </div>
|
|
||||||
<div class="form-group col-sm-12 text-muted small"> When using internal authentication, Portainer will encrypt user passwords and store credentials locally. </div>
|
|
||||||
|
|
||||||
<save-auth-settings-button on-save-settings="($ctrl.onSaveSettings)" save-button-state="($ctrl.saveButtonState)"></save-auth-settings-button>
|
|
|
@ -1,8 +1,22 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
import { getSettings, updateSettings } from './settings.service';
|
import { notifyError } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import {
|
||||||
|
publicSettings,
|
||||||
|
getSettings,
|
||||||
|
updateSettings,
|
||||||
|
} from './settings.service';
|
||||||
import { Settings } from './types';
|
import { Settings } from './types';
|
||||||
|
|
||||||
|
export function usePublicSettings() {
|
||||||
|
return useQuery(['settings', 'public'], () => publicSettings(), {
|
||||||
|
onError: (err) => {
|
||||||
|
notifyError('Failure', err as Error, 'Unable to retrieve settings');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSettings<T = Settings>(select?: (settings: Settings) => T) {
|
export function useSettings<T = Settings>(select?: (settings: Settings) => T) {
|
||||||
return useQuery(['settings'], getSettings, {
|
return useQuery(['settings'], getSettings, {
|
||||||
select,
|
select,
|
||||||
|
|
|
@ -93,6 +93,7 @@ export interface Settings {
|
||||||
LogoURL: string;
|
LogoURL: string;
|
||||||
BlackListedLabels: Pair[];
|
BlackListedLabels: Pair[];
|
||||||
AuthenticationMethod: AuthenticationMethod;
|
AuthenticationMethod: AuthenticationMethod;
|
||||||
|
InternalAuthSettings: { RequiredPasswordLength: number };
|
||||||
LDAPSettings: LDAPSettings;
|
LDAPSettings: LDAPSettings;
|
||||||
OAuthSettings: OAuthSettings;
|
OAuthSettings: OAuthSettings;
|
||||||
openAMTConfiguration: OpenAMTConfiguration;
|
openAMTConfiguration: OpenAMTConfiguration;
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-header icon="fa-lock" title-text="Change user password"></rd-widget-header>
|
<rd-widget-header icon="fa-lock" title-text="Change user password"></rd-widget-header>
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal" style="margin-top: 15px">
|
<form name="form" class="form-horizontal" style="margin-top: 15px">
|
||||||
<!-- current-password-input -->
|
<!-- current-password-input -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="current_password" class="col-sm-2 control-label text-left">Current password</label>
|
<label for="current_password" class="col-sm-2 control-label text-left">Current password</label>
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||||
<input type="password" class="form-control" ng-model="formValues.newPassword" ng-change="onNewPasswordChange()" id="new_password" />
|
<input type="password" class="form-control" ng-model="formValues.newPassword" ng-minlength="requiredPasswordLength" id="new_password" name="new_password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,7 +44,9 @@
|
||||||
<span class="input-group-addon"
|
<span class="input-group-addon"
|
||||||
><i
|
><i
|
||||||
ng-class="
|
ng-class="
|
||||||
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.newPassword !== '' && formValues.newPassword === formValues.confirmPassword]
|
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[
|
||||||
|
form.new_password.$viewValue !== '' && form.new_password.$viewValue === formValues.confirmPassword
|
||||||
|
]
|
||||||
"
|
"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i
|
></i
|
||||||
|
@ -52,17 +54,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<password-check-hint ng-if="!form.new_password.$valid || (forceChangePassword && !formValues.newPassword)"></password-check-hint>
|
||||||
|
<div ng-if="userRole === 1">
|
||||||
|
<p class="text-muted">
|
||||||
|
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true"></i>
|
||||||
|
Minimum password length is set <a ui-sref="portainer.settings.authentication">here.</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<!-- !confirm-password-input -->
|
<!-- !confirm-password-input -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
ng-disabled="isDemoUser || (AuthenticationMethod !== 1 && !initialUser) || !formValues.currentPassword || !passwordStrength || formValues.newPassword !== formValues.confirmPassword"
|
ng-disabled="isDemoUser || (AuthenticationMethod !== 1 && !initialUser) || !formValues.currentPassword || !formValues.newPassword || !formValues.confirmPassword || form.$invalid || form.new_password.$viewValue !== formValues.confirmPassword"
|
||||||
ng-click="updatePassword()"
|
ng-click="updatePassword()"
|
||||||
>
|
>
|
||||||
Update password
|
Update password
|
||||||
</button>
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" ng-click="skipPasswordChange()" ng-if="forceChangePassword && timesPasswordChangeSkipped < 2"
|
||||||
|
>Remind me later</button
|
||||||
|
>
|
||||||
<span class="text-muted small" style="margin-left: 5px" ng-if="AuthenticationMethod === 2 && !initialUser">
|
<span class="text-muted small" style="margin-left: 5px" ng-if="AuthenticationMethod === 2 && !initialUser">
|
||||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||||
You cannot change your password when using LDAP authentication.
|
You cannot change your password when using LDAP authentication.
|
||||||
|
@ -73,9 +85,6 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<force-password-update-hint ng-if="forceChangePassword"></force-password-update-hint>
|
|
||||||
<password-check-hint ng-if="!forceChangePassword && !passwordStrength"></password-check-hint>
|
|
||||||
</form>
|
</form>
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
</rd-widget>
|
</rd-widget>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { MinPasswordLen, StrengthCheck } from 'Portainer/helpers/password';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('AccountController', [
|
angular.module('portainer.app').controller('AccountController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$state',
|
'$state',
|
||||||
|
@ -18,15 +16,13 @@ angular.module('portainer.app').controller('AccountController', [
|
||||||
userTheme: '',
|
userTheme: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.passwordStrength = false;
|
|
||||||
$scope.MinPasswordLen = MinPasswordLen;
|
|
||||||
|
|
||||||
$scope.updatePassword = async function () {
|
$scope.updatePassword = async function () {
|
||||||
const confirmed = await ModalService.confirmChangePassword();
|
const confirmed = await ModalService.confirmChangePassword();
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword);
|
await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword);
|
||||||
Notifications.success('Success', 'Password successfully updated');
|
Notifications.success('Success', 'Password successfully updated');
|
||||||
|
StateManager.resetPasswordChangeSkips($scope.userID);
|
||||||
$scope.forceChangePassword = false;
|
$scope.forceChangePassword = false;
|
||||||
$state.go('portainer.logout');
|
$state.go('portainer.logout');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -35,11 +31,26 @@ angular.module('portainer.app').controller('AccountController', [
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.onNewPasswordChange = function () {
|
$scope.skipPasswordChange = async function () {
|
||||||
$scope.passwordStrength = StrengthCheck($scope.formValues.newPassword);
|
try {
|
||||||
|
if ($scope.userCanSkip()) {
|
||||||
|
StateManager.setPasswordChangeSkipped($scope.userID);
|
||||||
|
$scope.forceChangePassword = false;
|
||||||
|
$state.go('portainer.home');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Notifications.error('Failure', err, err.msg);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.uiCanExit = () => {
|
$scope.userCanSkip = function () {
|
||||||
|
return $scope.timesPasswordChangeSkipped < 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.uiCanExit = (newTransition) => {
|
||||||
|
if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if ($scope.forceChangePassword) {
|
if ($scope.forceChangePassword) {
|
||||||
ModalService.confirmForceChangePassword();
|
ModalService.confirmForceChangePassword();
|
||||||
}
|
}
|
||||||
|
@ -100,6 +111,7 @@ angular.module('portainer.app').controller('AccountController', [
|
||||||
const state = StateManager.getState();
|
const state = StateManager.getState();
|
||||||
const userDetails = Authentication.getUserDetails();
|
const userDetails = Authentication.getUserDetails();
|
||||||
$scope.userID = userDetails.ID;
|
$scope.userID = userDetails.ID;
|
||||||
|
$scope.userRole = Authentication.getUserDetails().role;
|
||||||
$scope.forceChangePassword = userDetails.forceChangePassword;
|
$scope.forceChangePassword = userDetails.forceChangePassword;
|
||||||
|
|
||||||
if (state.application.demoEnvironment.enabled) {
|
if (state.application.demoEnvironment.enabled) {
|
||||||
|
@ -113,6 +125,16 @@ angular.module('portainer.app').controller('AccountController', [
|
||||||
SettingsService.publicSettings()
|
SettingsService.publicSettings()
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
$scope.AuthenticationMethod = data.AuthenticationMethod;
|
$scope.AuthenticationMethod = data.AuthenticationMethod;
|
||||||
|
|
||||||
|
if (state.UI.requiredPasswordLength && state.UI.requiredPasswordLength !== data.RequiredPasswordLength) {
|
||||||
|
StateManager.clearPasswordChangeSkips($scope.userID);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.timesPasswordChangeSkipped =
|
||||||
|
state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[$scope.userID] ? state.UI.timesPasswordChangeSkipped[$scope.userID] : 0;
|
||||||
|
|
||||||
|
$scope.requiredPasswordLength = data.RequiredPasswordLength;
|
||||||
|
StateManager.setRequiredPasswordLength(data.RequiredPasswordLength);
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
<!-- !toggle -->
|
<!-- !toggle -->
|
||||||
|
|
||||||
<!-- init password form -->
|
<!-- init password form -->
|
||||||
<form class="simple-box-form form-horizontal" style="padding-left: 30px" ng-if="state.showInitPassword">
|
<form name="form" class="simple-box-form form-horizontal" style="padding-left: 30px" ng-if="state.showInitPassword">
|
||||||
<!-- note -->
|
<!-- note -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
@ -41,7 +41,7 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password" class="col-sm-4 control-label text-left">Password</label>
|
<label for="password" class="col-sm-4 control-label text-left">Password</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<input type="password" class="form-control" ng-model="formValues.Password" id="password" ng-change="onPasswordChange()" auto-focus />
|
<input type="password" class="form-control" ng-model="formValues.Password" id="password" name="password" ng-minlength="requiredPasswordLength" auto-focus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !new-password-input -->
|
<!-- !new-password-input -->
|
||||||
|
@ -53,7 +53,11 @@
|
||||||
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password" />
|
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password" />
|
||||||
<span class="input-group-addon"
|
<span class="input-group-addon"
|
||||||
><i
|
><i
|
||||||
ng-class="{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.Password !== '' && formValues.Password === formValues.ConfirmPassword]"
|
ng-class="
|
||||||
|
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[
|
||||||
|
form.password.$viewValue !== '' && form.password.$viewValue === formValues.ConfirmPassword
|
||||||
|
]
|
||||||
|
"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i
|
></i
|
||||||
></span>
|
></span>
|
||||||
|
@ -63,12 +67,10 @@
|
||||||
<!-- !confirm-password-input -->
|
<!-- !confirm-password-input -->
|
||||||
<!-- note -->
|
<!-- note -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12 text-muted" ng-if="!state.passwordStrength">
|
<div class="col-sm-12 text-muted" ng-if="!form.password.$valid">
|
||||||
<!-- below code is duplicated with component of <force-password-update-hint> -->
|
|
||||||
<!-- it is a workaround for firefox that does not render component <force-password-update-hint> -->
|
|
||||||
<p>
|
<p>
|
||||||
<i class="fa fa-times red-icon space-right" aria-hidden="true"></i>
|
<i class="fa fa-exclamation-triangle orange-icon space-right" aria-hidden="true"></i>
|
||||||
<span>The password must be at least {{ MinPasswordLen }} characters long.</span>
|
<span>The password must be at least {{ requiredPasswordLength }} characters long.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -79,7 +81,7 @@
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
ng-disabled="state.actionInProgress || !state.passwordStrength || formValues.Password !== formValues.ConfirmPassword"
|
ng-disabled="state.actionInProgress || form.$invalid || !formValues.Password || !formValues.ConfirmPassword || form.password.$viewValue !== formValues.ConfirmPassword"
|
||||||
ng-click="createAdminUser()"
|
ng-click="createAdminUser()"
|
||||||
button-spinner="state.actionInProgress"
|
button-spinner="state.actionInProgress"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { MinPasswordLen, StrengthCheck } from 'Portainer/helpers/password';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('InitAdminController', [
|
angular.module('portainer.app').controller('InitAdminController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$state',
|
'$state',
|
||||||
|
@ -27,17 +25,10 @@ angular.module('portainer.app').controller('InitAdminController', [
|
||||||
actionInProgress: false,
|
actionInProgress: false,
|
||||||
showInitPassword: true,
|
showInitPassword: true,
|
||||||
showRestorePortainer: false,
|
showRestorePortainer: false,
|
||||||
passwordStrength: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.MinPasswordLen = MinPasswordLen;
|
|
||||||
|
|
||||||
createAdministratorFlow();
|
createAdministratorFlow();
|
||||||
|
|
||||||
$scope.onPasswordChange = function () {
|
|
||||||
$scope.state.passwordStrength = StrengthCheck($scope.formValues.Password);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.togglePanel = function () {
|
$scope.togglePanel = function () {
|
||||||
$scope.state.showInitPassword = !$scope.state.showInitPassword;
|
$scope.state.showInitPassword = !$scope.state.showInitPassword;
|
||||||
$scope.state.showRestorePortainer = !$scope.state.showRestorePortainer;
|
$scope.state.showRestorePortainer = !$scope.state.showRestorePortainer;
|
||||||
|
@ -88,6 +79,14 @@ angular.module('portainer.app').controller('InitAdminController', [
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAdministratorFlow() {
|
function createAdministratorFlow() {
|
||||||
|
SettingsService.publicSettings()
|
||||||
|
.then(function success(data) {
|
||||||
|
$scope.requiredPasswordLength = data.RequiredPasswordLength;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||||
|
});
|
||||||
|
|
||||||
UserService.administratorExists()
|
UserService.administratorExists()
|
||||||
.then(function success(exists) {
|
.then(function success(exists) {
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
|
|
@ -31,7 +31,13 @@
|
||||||
<div class="col-sm-12 form-section-title"> Authentication method </div>
|
<div class="col-sm-12 form-section-title"> Authentication method </div>
|
||||||
<box-selector radio-name="'authOptions'" value="authMethod" options="authOptions" on-change="(onChangeAuthMethod)"></box-selector>
|
<box-selector radio-name="'authOptions'" value="authMethod" options="authOptions" on-change="(onChangeAuthMethod)"></box-selector>
|
||||||
|
|
||||||
<internal-auth ng-if="authenticationMethodSelected(1)" on-save-settings="(saveSettings)" save-button-state="state.actionInProgress"></internal-auth>
|
<internal-auth
|
||||||
|
ng-if="authenticationMethodSelected(1)"
|
||||||
|
on-save-settings="(saveSettings)"
|
||||||
|
is-loading="state.actionInProgress"
|
||||||
|
value="settings.InternalAuthSettings"
|
||||||
|
on-change="(onChangePasswordLength)"
|
||||||
|
></internal-auth>
|
||||||
|
|
||||||
<ldap-settings
|
<ldap-settings
|
||||||
ng-if="authenticationMethodSelected(2)"
|
ng-if="authenticationMethodSelected(2)"
|
||||||
|
|
|
@ -71,6 +71,12 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
|
||||||
$scope.settings.AuthenticationMethod = value;
|
$scope.settings.AuthenticationMethod = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.onChangePasswordLength = function onChangePasswordLength(value) {
|
||||||
|
$scope.$evalAsync(() => {
|
||||||
|
$scope.settings.InternalAuthSettings = { RequiredPasswordLength: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$scope.authenticationMethodSelected = function authenticationMethodSelected(value) {
|
$scope.authenticationMethodSelected = function authenticationMethodSelected(value) {
|
||||||
if (!$scope.settings) {
|
if (!$scope.settings) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -50,14 +50,14 @@
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-header icon="fa-lock" title-text="Change user password"></rd-widget-header>
|
<rd-widget-header icon="fa-lock" title-text="Change user password"></rd-widget-header>
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal" style="margin-top: 15px">
|
<form name="form" class="form-horizontal" style="margin-top: 15px">
|
||||||
<!-- new-password-input -->
|
<!-- new-password-input -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new_password" class="col-sm-2 control-label text-left">New password</label>
|
<label for="new_password" class="col-sm-2 control-label text-left">New password</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||||
<input type="password" class="form-control" ng-model="formValues.newPassword" id="new_password" />
|
<input type="password" class="form-control" ng-model="formValues.newPassword" id="new_password" name="new_password" ng-minlength="requiredPasswordLength" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -72,7 +72,9 @@
|
||||||
<span class="input-group-addon"
|
<span class="input-group-addon"
|
||||||
><i
|
><i
|
||||||
ng-class="
|
ng-class="
|
||||||
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.newPassword !== '' && formValues.newPassword === formValues.confirmPassword]
|
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[
|
||||||
|
form.new_password.$viewValue !== '' && form.new_password.$viewValue === formValues.confirmPassword
|
||||||
|
]
|
||||||
"
|
"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i
|
></i
|
||||||
|
@ -81,12 +83,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !confirm-password-input -->
|
<!-- !confirm-password-input -->
|
||||||
|
<password-check-hint ng-if="!form.new_password.$valid"></password-check-hint>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-2">
|
<div class="col-sm-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
ng-disabled="formValues.newPassword === '' || formValues.newPassword !== formValues.confirmPassword"
|
ng-disabled="form.new_password.$viewValue === '' || form.$invalid || !formValues.newPassword || !formValues.confirmPassword || form.new_password.$viewValue !== formValues.confirmPassword"
|
||||||
ng-click="updatePassword()"
|
ng-click="updatePassword()"
|
||||||
>Update password</button
|
>Update password</button
|
||||||
>
|
>
|
||||||
|
|
|
@ -120,6 +120,7 @@ angular.module('portainer.app').controller('UserController', [
|
||||||
$scope.formValues.Administrator = user.Role === 1;
|
$scope.formValues.Administrator = user.Role === 1;
|
||||||
$scope.formValues.username = user.Username;
|
$scope.formValues.username = user.Username;
|
||||||
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
||||||
|
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve user information');
|
Notifications.error('Failure', err, 'Unable to retrieve user information');
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-header icon="fa-plus" title-text="Add a new user"> </rd-widget-header>
|
<rd-widget-header icon="fa-plus" title-text="Add a new user"> </rd-widget-header>
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal">
|
<form name="form" class="form-horizontal">
|
||||||
<!-- name-input -->
|
<!-- name-input -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username" class="col-sm-3 col-lg-2 control-label text-left">
|
<label for="username" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
@ -46,7 +46,15 @@
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||||
<input type="password" class="form-control" ng-model="formValues.Password" id="password" ng-change="onPasswordChange()" data-cy="user-passwordInput" />
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
ng-model="formValues.Password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
ng-minlength="requiredPasswordLength"
|
||||||
|
data-cy="user-passwordInput"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -60,7 +68,9 @@
|
||||||
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password" data-cy="user-passwordConfirmInput" />
|
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password" data-cy="user-passwordConfirmInput" />
|
||||||
<span class="input-group-addon"
|
<span class="input-group-addon"
|
||||||
><i
|
><i
|
||||||
ng-class="{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.Password !== '' && formValues.Password === formValues.ConfirmPassword]"
|
ng-class="
|
||||||
|
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[form.password.$viewValue !== '' && form.password.$viewValue === formValues.ConfirmPassword]
|
||||||
|
"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i
|
></i
|
||||||
></span>
|
></span>
|
||||||
|
@ -70,7 +80,7 @@
|
||||||
<!-- !confirm-password-input -->
|
<!-- !confirm-password-input -->
|
||||||
|
|
||||||
<!-- password-check-hint -->
|
<!-- password-check-hint -->
|
||||||
<div class="form-group" ng-if="AuthenticationMethod === 1 && !state.passwordStrength">
|
<div class="form-group" ng-if="AuthenticationMethod === 1 && !form.password.$valid">
|
||||||
<div class="col-sm-3 col-lg-2"></div>
|
<div class="col-sm-3 col-lg-2"></div>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<password-check-hint></password-check-hint>
|
<password-check-hint></password-check-hint>
|
||||||
|
@ -130,7 +140,7 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && !state.passwordStrength) || (AuthenticationMethod === 1 && formValues.Password !== formValues.ConfirmPassword)"
|
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || !formValues.Password || !formValues.ConfirmPassword || (AuthenticationMethod === 1 && form.$invalid) || (AuthenticationMethod === 1 && form.password.$viewValue !== formValues.ConfirmPassword)"
|
||||||
ng-click="addUser()"
|
ng-click="addUser()"
|
||||||
button-spinner="state.actionInProgress"
|
button-spinner="state.actionInProgress"
|
||||||
data-cy="user-createUserButton"
|
data-cy="user-createUserButton"
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import { StrengthCheck } from 'Portainer/helpers/password';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('UsersController', [
|
angular.module('portainer.app').controller('UsersController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -17,7 +16,6 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
userCreationError: '',
|
userCreationError: '',
|
||||||
validUsername: false,
|
validUsername: false,
|
||||||
actionInProgress: false,
|
actionInProgress: false,
|
||||||
passwordStrength: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
|
@ -28,10 +26,6 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
Teams: [],
|
Teams: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.onPasswordChange = function () {
|
|
||||||
$scope.state.passwordStrength = StrengthCheck($scope.formValues.Password);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.checkUsernameValidity = function () {
|
$scope.checkUsernameValidity = function () {
|
||||||
var valid = true;
|
var valid = true;
|
||||||
for (var i = 0; i < $scope.users.length; i++) {
|
for (var i = 0; i < $scope.users.length; i++) {
|
||||||
|
@ -128,6 +122,7 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
$scope.users = users;
|
$scope.users = users;
|
||||||
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
|
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
|
||||||
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
||||||
|
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve users and teams');
|
Notifications.error('Failure', err, 'Unable to retrieve users and teams');
|
||||||
|
|
|
@ -124,6 +124,7 @@
|
||||||
"rc-slider": "^9.7.5",
|
"rc-slider": "^9.7.5",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-feather": "^2.0.9",
|
||||||
"react-i18next": "^11.12.0",
|
"react-i18next": "^11.12.0",
|
||||||
"react-is": "^17.0.2",
|
"react-is": "^17.0.2",
|
||||||
"react-query": "^3.34.3",
|
"react-query": "^3.34.3",
|
||||||
|
|
|
@ -14275,6 +14275,13 @@ react-fast-compare@^3.0.1, react-fast-compare@^3.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
|
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
|
||||||
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
|
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
|
||||||
|
|
||||||
|
react-feather@^2.0.9:
|
||||||
|
version "2.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.9.tgz#6e42072130d2fa9a09d4476b0e61b0ed17814480"
|
||||||
|
integrity sha512-yMfCGRkZdXwIs23Zw/zIWCJO3m3tlaUvtHiXlW+3FH7cIT6fiK1iJ7RJWugXq7Fso8ZaQyUm92/GOOHXvkiVUw==
|
||||||
|
dependencies:
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
|
||||||
react-helmet-async@^1.0.7:
|
react-helmet-async@^1.0.7:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.2.2.tgz#38d58d32ebffbc01ba42b5ad9142f85722492389"
|
resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.2.2.tgz#38d58d32ebffbc01ba42b5ad9142f85722492389"
|
||||||
|
|
Loading…
Reference in New Issue