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 (
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
|
@ -47,6 +47,9 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
|||
EnableTelemetry: true,
|
||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||
BlackListedLabels: make([]portainer.Pair, 0),
|
||||
InternalAuthSettings: portainer.InternalAuthSettings{
|
||||
RequiredPasswordLength: 12,
|
||||
},
|
||||
LDAPSettings: portainer.LDAPSettings{
|
||||
AnonymousMode: true,
|
||||
AutoCreateUsers: true,
|
||||
|
|
|
@ -100,6 +100,9 @@ func (m *Migrator) Migrate() error {
|
|||
|
||||
// Portainer 2.13
|
||||
newMigration(40, m.migrateDBVersionToDB40),
|
||||
|
||||
// Portainer 2.14
|
||||
newMigration(50, m.migrateDBVersionToDB50),
|
||||
}
|
||||
|
||||
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,
|
||||
"FeatureFlagSettings": null,
|
||||
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
||||
"InternalAuthSettings": {
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell",
|
||||
"LDAPSettings": {
|
||||
|
|
|
@ -13,7 +13,6 @@ import (
|
|||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
||||
)
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
forceChangePassword := !passwordutils.StrengthCheck(password)
|
||||
forceChangePassword := !handler.passwordStrengthChecker.Check(password)
|
||||
return handler.writeToken(w, user, forceChangePassword)
|
||||
}
|
||||
|
||||
|
|
|
@ -22,12 +22,14 @@ type Handler struct {
|
|||
OAuthService portainer.OAuthService
|
||||
ProxyManager *proxy.Manager
|
||||
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
passwordStrengthChecker security.PasswordStrengthChecker
|
||||
}
|
||||
|
||||
// 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{
|
||||
Router: mux.NewRouter(),
|
||||
Router: mux.NewRouter(),
|
||||
passwordStrengthChecker: passwordStrengthChecker,
|
||||
}
|
||||
|
||||
h.Handle("/auth/oauth/validate",
|
||||
|
|
|
@ -14,6 +14,8 @@ type publicSettingsResponse struct {
|
|||
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
|
||||
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
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
|
||||
// Supported feature flags
|
||||
|
@ -51,6 +53,7 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
|
|||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: appSettings.LogoURL,
|
||||
AuthenticationMethod: appSettings.AuthenticationMethod,
|
||||
RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength,
|
||||
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
|
||||
EnableTelemetry: appSettings.EnableTelemetry,
|
||||
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
|
||||
BlackListedLabels []portainer.Pair
|
||||
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
||||
AuthenticationMethod *int `example:"1"`
|
||||
LDAPSettings *portainer.LDAPSettings `example:""`
|
||||
OAuthSettings *portainer.OAuthSettings `example:""`
|
||||
AuthenticationMethod *int `example:"1"`
|
||||
InternalAuthSettings *portainer.InternalAuthSettings `example:""`
|
||||
LDAPSettings *portainer.LDAPSettings `example:""`
|
||||
OAuthSettings *portainer.OAuthSettings `example:""`
|
||||
// The interval in which environment(endpoint) snapshots are created
|
||||
SnapshotInterval *string `example:"5m"`
|
||||
// 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
|
||||
}
|
||||
|
||||
if payload.InternalAuthSettings != nil {
|
||||
settings.InternalAuthSettings.RequiredPasswordLength = payload.InternalAuthSettings.RequiredPasswordLength
|
||||
}
|
||||
|
||||
if payload.LDAPSettings != nil {
|
||||
ldapReaderDN := settings.LDAPSettings.ReaderDN
|
||||
ldapPassword := settings.LDAPSettings.Password
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
||||
)
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
if !passwordutils.StrengthCheck(payload.Password) {
|
||||
if !handler.passwordStrengthChecker.Check(payload.Password) {
|
||||
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.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
bouncer *security.RequestBouncer
|
||||
apiKeyService apikey.APIKeyService
|
||||
demoService *demo.Service
|
||||
DataStore dataservices.DataStore
|
||||
CryptoService portainer.CryptoService
|
||||
bouncer *security.RequestBouncer
|
||||
apiKeyService apikey.APIKeyService
|
||||
demoService *demo.Service
|
||||
DataStore dataservices.DataStore
|
||||
CryptoService portainer.CryptoService
|
||||
passwordStrengthChecker security.PasswordStrengthChecker
|
||||
}
|
||||
|
||||
// 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{
|
||||
Router: mux.NewRouter(),
|
||||
bouncer: bouncer,
|
||||
apiKeyService: apiKeyService,
|
||||
demoService: demoService,
|
||||
Router: mux.NewRouter(),
|
||||
bouncer: bouncer,
|
||||
apiKeyService: apiKeyService,
|
||||
demoService: demoService,
|
||||
passwordStrengthChecker: passwordStrengthChecker,
|
||||
}
|
||||
h.Handle("/users",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost)
|
||||
|
|
|
@ -11,7 +11,6 @@ import (
|
|||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
||||
)
|
||||
|
||||
type userCreatePayload struct {
|
||||
|
@ -95,7 +94,7 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
|
|||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,8 +39,9 @@ func Test_userCreateAccessToken(t *testing.T) {
|
|||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||
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
|
||||
|
||||
// generate standard and admin user tokens
|
||||
|
|
|
@ -31,8 +31,9 @@ func Test_deleteUserRemovesAccessTokens(t *testing.T) {
|
|||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||
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
|
||||
|
||||
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())
|
||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||
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
|
||||
|
||||
// generate standard and admin user tokens
|
||||
|
|
|
@ -36,8 +36,9 @@ func Test_userRemoveAccessToken(t *testing.T) {
|
|||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||
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
|
||||
|
||||
// generate standard and admin user tokens
|
||||
|
|
|
@ -12,7 +12,6 @@ import (
|
|||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/passwordutils"
|
||||
)
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
if !passwordutils.StrengthCheck(payload.NewPassword) {
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
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())
|
||||
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
|
||||
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
|
||||
|
||||
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) {
|
||||
checker := NewPasswordStrengthChecker(settingsStub{minLength: 12})
|
||||
|
||||
type args struct {
|
||||
password string
|
||||
}
|
||||
|
@ -23,9 +29,21 @@ func TestStrengthCheck(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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.CryptoService = server.CryptoService
|
||||
authHandler.JWTService = server.JWTService
|
||||
|
@ -254,7 +256,7 @@ func (server *Server) Start() error {
|
|||
var uploadHandler = upload.NewHandler(requestBouncer)
|
||||
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.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
|
||||
}
|
||||
|
||||
// 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 struct {
|
||||
// The distinguished name of the element from which the LDAP server will search for groups
|
||||
|
@ -799,6 +804,7 @@ type (
|
|||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
// 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"`
|
||||
InternalAuthSettings InternalAuthSettings `json:"InternalAuthSettings" example:""`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
|
||||
OAuthSettings OAuthSettings `json:"OAuthSettings" 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 { 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>
|
||||
);
|
||||
}
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
|
||||
export function PasswordCheckHint() {
|
||||
const settingsQuery = usePublicSettings();
|
||||
const minPasswordLength = settingsQuery.data?.RequiredPasswordLength;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted">
|
||||
<i className="fa fa-times red-icon space-right" aria-hidden="true">
|
||||
{' '}
|
||||
</i>
|
||||
<span>
|
||||
The password must be at least {MinPasswordLen} characters long.
|
||||
</span>
|
||||
<i
|
||||
className="fa fa-exclamation-triangle orange-icon space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
The password must be at least {minPasswordLength} characters long.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ForcePasswordUpdateHintAngular = react2angular(
|
||||
ForcePasswordUpdateHint,
|
||||
[]
|
||||
);
|
||||
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []);
|
||||
|
|
|
@ -14,7 +14,7 @@ import { ReactExampleAngular } from './ReactExample';
|
|||
import { TooltipAngular } from './Tip/Tooltip';
|
||||
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
|
||||
import { InformationPanelAngular } from './InformationPanel';
|
||||
import { ForcePasswordUpdateHintAngular, PasswordCheckHintAngular } from './PasswordCheckHint';
|
||||
import { PasswordCheckHintAngular } from './PasswordCheckHint';
|
||||
import { ViewLoadingAngular } from './ViewLoading';
|
||||
|
||||
export default angular
|
||||
|
@ -24,6 +24,5 @@ export default angular
|
|||
.component('portainerTooltip', TooltipAngular)
|
||||
.component('reactExample', ReactExampleAngular)
|
||||
.component('beFeatureIndicator', beFeatureIndicatorAngular)
|
||||
.component('forcePasswordUpdateHint', ForcePasswordUpdateHintAngular)
|
||||
.component('passwordCheckHint', PasswordCheckHintAngular)
|
||||
.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.BlackListedLabels = data.BlackListedLabels;
|
||||
this.AuthenticationMethod = data.AuthenticationMethod;
|
||||
this.InternalAuthSettings = data.InternalAuthSettings;
|
||||
this.LDAPSettings = data.LDAPSettings;
|
||||
this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings);
|
||||
this.openAMTConfiguration = data.openAMTConfiguration;
|
||||
|
@ -23,6 +24,7 @@ export function SettingsViewModel(data) {
|
|||
|
||||
export function PublicSettingsViewModel(settings) {
|
||||
this.AuthenticationMethod = settings.AuthenticationMethod;
|
||||
this.RequiredPasswordLength = settings.RequiredPasswordLength;
|
||||
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||
this.EnforceEdgeID = settings.EnforceEdgeID;
|
||||
this.FeatureFlagSettings = settings.FeatureFlagSettings;
|
||||
|
@ -33,6 +35,10 @@ export function PublicSettingsViewModel(settings) {
|
|||
this.KubeconfigExpiry = settings.KubeconfigExpiry;
|
||||
}
|
||||
|
||||
export function InternalAuthSettingsViewModel(data) {
|
||||
this.RequiredPasswordLength = data.RequiredPasswordLength;
|
||||
}
|
||||
|
||||
export function LDAPSettingsViewModel(data) {
|
||||
this.ReaderDN = data.ReaderDN;
|
||||
this.Password = data.Password;
|
||||
|
|
|
@ -43,6 +43,27 @@ function StateManagerFactory(
|
|||
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 () {
|
||||
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 { autoUserProvisionToggle } from './auto-user-provision-toggle';
|
||||
import { saveAuthSettingsButton } from './save-auth-settings-button';
|
||||
import { internalAuth } from './internal-auth';
|
||||
import { InternalAuthAngular } from './internal-auth';
|
||||
|
||||
export default angular
|
||||
.module('portainer.settings.authentication', [ldapModule])
|
||||
.component('internalAuth', internalAuth)
|
||||
.component('internalAuth', InternalAuthAngular)
|
||||
.component('saveAuthSettingsButton', saveAuthSettingsButton)
|
||||
.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 { getSettings, updateSettings } from './settings.service';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import {
|
||||
publicSettings,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
} from './settings.service';
|
||||
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) {
|
||||
return useQuery(['settings'], getSettings, {
|
||||
select,
|
||||
|
|
|
@ -93,6 +93,7 @@ export interface Settings {
|
|||
LogoURL: string;
|
||||
BlackListedLabels: Pair[];
|
||||
AuthenticationMethod: AuthenticationMethod;
|
||||
InternalAuthSettings: { RequiredPasswordLength: number };
|
||||
LDAPSettings: LDAPSettings;
|
||||
OAuthSettings: OAuthSettings;
|
||||
openAMTConfiguration: OpenAMTConfiguration;
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<rd-widget>
|
||||
<rd-widget-header icon="fa-lock" title-text="Change user password"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" style="margin-top: 15px">
|
||||
<form name="form" class="form-horizontal" style="margin-top: 15px">
|
||||
<!-- current-password-input -->
|
||||
<div class="form-group">
|
||||
<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="input-group">
|
||||
<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>
|
||||
|
@ -44,7 +44,9 @@
|
|||
<span class="input-group-addon"
|
||||
><i
|
||||
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"
|
||||
></i
|
||||
|
@ -52,17 +54,27 @@
|
|||
</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 -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="submit"
|
||||
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()"
|
||||
>
|
||||
Update password
|
||||
</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">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
You cannot change your password when using LDAP authentication.
|
||||
|
@ -73,9 +85,6 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<force-password-update-hint ng-if="forceChangePassword"></force-password-update-hint>
|
||||
<password-check-hint ng-if="!forceChangePassword && !passwordStrength"></password-check-hint>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { MinPasswordLen, StrengthCheck } from 'Portainer/helpers/password';
|
||||
|
||||
angular.module('portainer.app').controller('AccountController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
|
@ -18,15 +16,13 @@ angular.module('portainer.app').controller('AccountController', [
|
|||
userTheme: '',
|
||||
};
|
||||
|
||||
$scope.passwordStrength = false;
|
||||
$scope.MinPasswordLen = MinPasswordLen;
|
||||
|
||||
$scope.updatePassword = async function () {
|
||||
const confirmed = await ModalService.confirmChangePassword();
|
||||
if (confirmed) {
|
||||
try {
|
||||
await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword);
|
||||
Notifications.success('Success', 'Password successfully updated');
|
||||
StateManager.resetPasswordChangeSkips($scope.userID);
|
||||
$scope.forceChangePassword = false;
|
||||
$state.go('portainer.logout');
|
||||
} catch (err) {
|
||||
|
@ -35,11 +31,26 @@ angular.module('portainer.app').controller('AccountController', [
|
|||
}
|
||||
};
|
||||
|
||||
$scope.onNewPasswordChange = function () {
|
||||
$scope.passwordStrength = StrengthCheck($scope.formValues.newPassword);
|
||||
$scope.skipPasswordChange = async function () {
|
||||
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) {
|
||||
ModalService.confirmForceChangePassword();
|
||||
}
|
||||
|
@ -100,6 +111,7 @@ angular.module('portainer.app').controller('AccountController', [
|
|||
const state = StateManager.getState();
|
||||
const userDetails = Authentication.getUserDetails();
|
||||
$scope.userID = userDetails.ID;
|
||||
$scope.userRole = Authentication.getUserDetails().role;
|
||||
$scope.forceChangePassword = userDetails.forceChangePassword;
|
||||
|
||||
if (state.application.demoEnvironment.enabled) {
|
||||
|
@ -113,6 +125,16 @@ angular.module('portainer.app').controller('AccountController', [
|
|||
SettingsService.publicSettings()
|
||||
.then(function success(data) {
|
||||
$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) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
<!-- !toggle -->
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
|
@ -41,7 +41,7 @@
|
|||
<div class="form-group">
|
||||
<label for="password" class="col-sm-4 control-label text-left">Password</label>
|
||||
<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>
|
||||
<!-- !new-password-input -->
|
||||
|
@ -53,7 +53,11 @@
|
|||
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password" />
|
||||
<span class="input-group-addon"
|
||||
><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"
|
||||
></i
|
||||
></span>
|
||||
|
@ -63,12 +67,10 @@
|
|||
<!-- !confirm-password-input -->
|
||||
<!-- note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 text-muted" ng-if="!state.passwordStrength">
|
||||
<!-- 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> -->
|
||||
<div class="col-sm-12 text-muted" ng-if="!form.password.$valid">
|
||||
<p>
|
||||
<i class="fa fa-times red-icon space-right" aria-hidden="true"></i>
|
||||
<span>The password must be at least {{ MinPasswordLen }} characters long.</span>
|
||||
<i class="fa fa-exclamation-triangle orange-icon space-right" aria-hidden="true"></i>
|
||||
<span>The password must be at least {{ requiredPasswordLength }} characters long.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -79,7 +81,7 @@
|
|||
<button
|
||||
type="submit"
|
||||
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()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { MinPasswordLen, StrengthCheck } from 'Portainer/helpers/password';
|
||||
|
||||
angular.module('portainer.app').controller('InitAdminController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
|
@ -27,17 +25,10 @@ angular.module('portainer.app').controller('InitAdminController', [
|
|||
actionInProgress: false,
|
||||
showInitPassword: true,
|
||||
showRestorePortainer: false,
|
||||
passwordStrength: false,
|
||||
};
|
||||
|
||||
$scope.MinPasswordLen = MinPasswordLen;
|
||||
|
||||
createAdministratorFlow();
|
||||
|
||||
$scope.onPasswordChange = function () {
|
||||
$scope.state.passwordStrength = StrengthCheck($scope.formValues.Password);
|
||||
};
|
||||
|
||||
$scope.togglePanel = function () {
|
||||
$scope.state.showInitPassword = !$scope.state.showInitPassword;
|
||||
$scope.state.showRestorePortainer = !$scope.state.showRestorePortainer;
|
||||
|
@ -88,6 +79,14 @@ angular.module('portainer.app').controller('InitAdminController', [
|
|||
}
|
||||
|
||||
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()
|
||||
.then(function success(exists) {
|
||||
if (exists) {
|
||||
|
|
|
@ -31,7 +31,13 @@
|
|||
<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>
|
||||
|
||||
<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
|
||||
ng-if="authenticationMethodSelected(2)"
|
||||
|
|
|
@ -71,6 +71,12 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
|
|||
$scope.settings.AuthenticationMethod = value;
|
||||
};
|
||||
|
||||
$scope.onChangePasswordLength = function onChangePasswordLength(value) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.settings.InternalAuthSettings = { RequiredPasswordLength: value };
|
||||
});
|
||||
};
|
||||
|
||||
$scope.authenticationMethodSelected = function authenticationMethodSelected(value) {
|
||||
if (!$scope.settings) {
|
||||
return false;
|
||||
|
|
|
@ -50,14 +50,14 @@
|
|||
<rd-widget>
|
||||
<rd-widget-header icon="fa-lock" title-text="Change user password"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" style="margin-top: 15px">
|
||||
<form name="form" class="form-horizontal" style="margin-top: 15px">
|
||||
<!-- new-password-input -->
|
||||
<div class="form-group">
|
||||
<label for="new_password" class="col-sm-2 control-label text-left">New password</label>
|
||||
<div class="col-sm-8">
|
||||
<div class="input-group">
|
||||
<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>
|
||||
|
@ -72,7 +72,9 @@
|
|||
<span class="input-group-addon"
|
||||
><i
|
||||
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"
|
||||
></i
|
||||
|
@ -81,12 +83,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !confirm-password-input -->
|
||||
<password-check-hint ng-if="!form.new_password.$valid"></password-check-hint>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-2">
|
||||
<button
|
||||
type="submit"
|
||||
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()"
|
||||
>Update password</button
|
||||
>
|
||||
|
|
|
@ -120,6 +120,7 @@ angular.module('portainer.app').controller('UserController', [
|
|||
$scope.formValues.Administrator = user.Role === 1;
|
||||
$scope.formValues.username = user.Username;
|
||||
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
||||
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve user information');
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<rd-widget>
|
||||
<rd-widget-header icon="fa-plus" title-text="Add a new user"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<form name="form" class="form-horizontal">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<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="input-group">
|
||||
<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>
|
||||
|
@ -60,7 +68,9 @@
|
|||
<input type="password" class="form-control" ng-model="formValues.ConfirmPassword" id="confirm_password" data-cy="user-passwordConfirmInput" />
|
||||
<span class="input-group-addon"
|
||||
><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"
|
||||
></i
|
||||
></span>
|
||||
|
@ -70,7 +80,7 @@
|
|||
<!-- !confirm-password-input -->
|
||||
|
||||
<!-- 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-8">
|
||||
<password-check-hint></password-check-hint>
|
||||
|
@ -130,7 +140,7 @@
|
|||
<button
|
||||
type="button"
|
||||
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()"
|
||||
button-spinner="state.actionInProgress"
|
||||
data-cy="user-createUserButton"
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import _ from 'lodash-es';
|
||||
import { StrengthCheck } from 'Portainer/helpers/password';
|
||||
|
||||
angular.module('portainer.app').controller('UsersController', [
|
||||
'$q',
|
||||
|
@ -17,7 +16,6 @@ angular.module('portainer.app').controller('UsersController', [
|
|||
userCreationError: '',
|
||||
validUsername: false,
|
||||
actionInProgress: false,
|
||||
passwordStrength: false,
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
|
@ -28,10 +26,6 @@ angular.module('portainer.app').controller('UsersController', [
|
|||
Teams: [],
|
||||
};
|
||||
|
||||
$scope.onPasswordChange = function () {
|
||||
$scope.state.passwordStrength = StrengthCheck($scope.formValues.Password);
|
||||
};
|
||||
|
||||
$scope.checkUsernameValidity = function () {
|
||||
var valid = true;
|
||||
for (var i = 0; i < $scope.users.length; i++) {
|
||||
|
@ -128,6 +122,7 @@ angular.module('portainer.app').controller('UsersController', [
|
|||
$scope.users = users;
|
||||
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
|
||||
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
||||
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve users and teams');
|
||||
|
|
|
@ -124,6 +124,7 @@
|
|||
"rc-slider": "^9.7.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-feather": "^2.0.9",
|
||||
"react-i18next": "^11.12.0",
|
||||
"react-is": "^17.0.2",
|
||||
"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"
|
||||
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:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.2.2.tgz#38d58d32ebffbc01ba42b5ad9142f85722492389"
|
||||
|
|
Loading…
Reference in New Issue