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
itsconquest 2022-06-03 16:00:13 +12:00 committed by GitHub
parent 4195d93a16
commit bca1c6b9cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 2486 additions and 2065 deletions

View File

@ -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"
)

View File

@ -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,

View File

@ -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

View File

@ -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)
}

View File

@ -690,6 +690,9 @@
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell",
"LDAPSettings": {

View File

@ -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)
}

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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}
}

View File

@ -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)

View File

@ -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}
}

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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}
}

View File

@ -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) {

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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:""`

File diff suppressed because it is too large Load Diff

View File

@ -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, []);

View File

@ -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;

View File

@ -1,9 +0,0 @@
export const MinPasswordLen = 12;
function lengthCheck(password: string) {
return password.length >= MinPasswordLen;
}
export function StrengthCheck(password: string) {
return lengthCheck(password);
}

View File

@ -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;

View File

@ -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;
};

View File

@ -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>
</>
);
}

View File

@ -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;

View File

@ -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',
]);

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -1,7 +0,0 @@
export const internalAuth = {
templateUrl: './internal-auth.html',
bindings: {
onSaveSettings: '<',
saveButtonState: '<',
},
};

View File

@ -0,0 +1 @@
export { InternalAuthAngular, InternalAuth } from './InternalAuth';

View File

@ -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>

View File

@ -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,

View File

@ -93,6 +93,7 @@ export interface Settings {
LogoURL: string;
BlackListedLabels: Pair[];
AuthenticationMethod: AuthenticationMethod;
InternalAuthSettings: { RequiredPasswordLength: number };
LDAPSettings: LDAPSettings;
OAuthSettings: OAuthSettings;
openAMTConfiguration: OpenAMTConfiguration;

View File

@ -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>

View File

@ -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');

View File

@ -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"
>

View File

@ -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) {

View File

@ -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)"

View File

@ -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;

View File

@ -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
>

View File

@ -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');

View File

@ -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"

View File

@ -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');

View File

@ -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",

View File

@ -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"