feat(password) EE-2690 enforce strong password policy (#6751)

* feat(password) EE-2690 enforce strong password policy

* feat(password) EE-2690 disable create user button if password is not valid

* feat(password) EE-2690 show force password change warning only when week password is detected

* feat(password) EE-2690 prevent users leave account page by clicking add access token button

Co-authored-by: Simon Meng <simon.meng@portainer.io>
pull/6662/head^2
cong meng 3 years ago committed by GitHub
parent 9ebc963082
commit 85ad4e334a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -13,6 +13,7 @@ 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 {
@ -100,7 +101,8 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
}
return handler.writeToken(w, user)
forceChangePassword := !passwordutils.StrengthCheck(password)
return handler.writeToken(w, user, forceChangePassword)
}
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
@ -131,11 +133,11 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
}
return handler.writeToken(w, user)
return handler.writeToken(w, user, false)
}
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
tokenData := composeTokenData(user)
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User, forceChangePassword bool) *httperror.HandlerError {
tokenData := composeTokenData(user, forceChangePassword)
return handler.persistAndWriteToken(w, tokenData)
}
@ -206,10 +208,11 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM
return false
}
func composeTokenData(user *portainer.User) *portainer.TokenData {
func composeTokenData(user *portainer.User, forceChangePassword bool) *portainer.TokenData {
return &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
ID: user.ID,
Username: user.Username,
Role: user.Role,
ForceChangePassword: forceChangePassword,
}
}

@ -110,5 +110,5 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
}
return handler.writeToken(w, user)
return handler.writeToken(w, user, false)
}

@ -9,6 +9,7 @@ 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 {
@ -57,6 +58,10 @@ 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) {
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
}
user := &portainer.User{
Username: payload.Username,
Role: portainer.AdministratorRole,

@ -11,6 +11,7 @@ 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 {
@ -94,6 +95,10 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
if !passwordutils.StrengthCheck(payload.Password) {
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
}
user.Password, err = handler.CryptoService.Hash(payload.Password)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure}

@ -12,6 +12,7 @@ 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 {
@ -81,6 +82,10 @@ 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) {
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure}

@ -0,0 +1,33 @@
package passwordutils
import (
"regexp"
)
const MinPasswordLen = 12
func lengthCheck(password string) bool {
return len(password) >= MinPasswordLen
}
func comboCheck(password string) bool {
count := 0
regexps := [4]*regexp.Regexp{
regexp.MustCompile(`[a-z]`),
regexp.MustCompile(`[A-Z]`),
regexp.MustCompile(`[0-9]`),
regexp.MustCompile(`[\W_]`),
}
for _, re := range regexps {
if re.FindString(password) != "" {
count += 1
}
}
return count >= 3
}
func StrengthCheck(password string) bool {
return lengthCheck(password) && comboCheck(password)
}

@ -0,0 +1,31 @@
package passwordutils
import "testing"
func TestStrengthCheck(t *testing.T) {
type args struct {
password string
}
tests := []struct {
name string
args args
wantStrong bool
}{
{"Empty password", args{""}, false},
{"Short password", args{"portainer"}, false},
{"Short password", args{"portaienr!@#"}, false},
{"Week password", args{"12345678!@#"}, false},
{"Week password", args{"portaienr123"}, false},
{"Good password", args{"Portainer123"}, true},
{"Good password", args{"Portainer___"}, true},
{"Good password", args{"^portainer12"}, true},
{"Good password", args{"12%PORTAINER"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotStrong := StrengthCheck(tt.args.password); gotStrong != tt.wantStrong {
t.Errorf("StrengthCheck() = %v, want %v", gotStrong, tt.wantStrong)
}
})
}
}

@ -22,10 +22,11 @@ type Service struct {
}
type claims struct {
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
Scope scope `json:"scope"`
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
Scope scope `json:"scope"`
ForceChangePassword bool `json:"forceChangePassword"`
jwt.StandardClaims
}
@ -164,10 +165,11 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
}
cl := claims{
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
Scope: scope,
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
Scope: scope,
ForceChangePassword: data.ForceChangePassword,
StandardClaims: jwt.StandardClaims{
ExpiresAt: expiresAt,
IssuedAt: time.Now().Unix(),

@ -1102,9 +1102,10 @@ type (
// TokenData represents the data embedded in a JWT token
TokenData struct {
ID UserID
Username string
Role UserRole
ID UserID
Username string
Role UserRole
ForceChangePassword bool
}
// TunnelDetails represents information associated to a tunnel

@ -897,6 +897,46 @@ json-tree .branch-preview {
flex-wrap: wrap;
}
.ml-0 {
margin-left: 0rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.ml-4 {
margin-left: 1rem;
}
.ml-5 {
margin-left: 1.25rem;
}
.ml-5 {
margin-left: 1.5rem;
}
.ml-6 {
margin-left: 1.75rem;
}
.ml-7 {
margin-left: 2rem;
}
.ml-8 {
margin-left: 2.25rem;
}
.text-wrap {
word-break: break-all;
white-space: normal;

@ -0,0 +1,59 @@
import { react2angular } from '@/react-tools/react2angular';
import { MinPasswordLen } from '../helpers/password';
function PasswordCombination() {
return (
<ul className="text-muted">
<li className="ml-8"> Special characters </li>
<li className="ml-8"> Lower case characters </li>
<li className="ml-8"> Upper case characters </li>
<li className="ml-8"> Numeric characters </li>
</ul>
);
}
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">
To ensure the security of your account, please update your password to a
stronger password using a combination of at least 3 of the following:
</p>
<PasswordCombination />
</div>
);
}
export function PasswordCheckHint() {
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,
including a combination of one character of three of the below:
</span>
</p>
<PasswordCombination />
</div>
);
}
export const ForcePasswordUpdateHintAngular = react2angular(
ForcePasswordUpdateHint,
[]
);
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []);

@ -1,8 +1,14 @@
export default class AccessTokensDatatableController {
/* @ngInject*/
constructor($scope, $controller, DatatableService) {
constructor($scope, $state, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.onClickAdd = () => {
if (this.uiCanExit()) {
$state.go('portainer.account.new-access-token');
}
};
this.$onInit = function () {
this.setDefaults();
this.prepareTableFromDataset();

@ -14,9 +14,7 @@
>
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.account.new-access-token">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add access token
</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.onClickAdd()"> <i class="fa fa-plus space-right" aria-hidden="true"></i>Add access token </button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>

@ -11,5 +11,6 @@ angular.module('portainer.app').component('accessTokensDatatable', {
tableKey: '@',
orderBy: '@',
removeAction: '<',
uiCanExit: '<',
},
});

@ -14,6 +14,7 @@ import { ReactExampleAngular } from './ReactExample';
import { TooltipAngular } from './Tip/Tooltip';
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
import { InformationPanelAngular } from './InformationPanel';
import { ForcePasswordUpdateHintAngular, PasswordCheckHintAngular } from './PasswordCheckHint';
export default angular
.module('portainer.app.components', [headerModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
@ -21,4 +22,6 @@ export default angular
.component('portainerTooltip', TooltipAngular)
.component('reactExample', ReactExampleAngular)
.component('beFeatureIndicator', beFeatureIndicatorAngular)
.component('forcePasswordUpdateHint', ForcePasswordUpdateHintAngular)
.component('passwordCheckHint', PasswordCheckHintAngular)
.component('createAccessToken', CreateAccessTokenAngular).name;

@ -0,0 +1,22 @@
export const MinPasswordLen = 12;
function lengthCheck(password: string) {
return password.length >= MinPasswordLen;
}
function comboCheck(password: string) {
let count = 0;
const regexps = [/[a-z]/, /[A-Z]/, /[0-9]/, /[\W_]/];
regexps.forEach((re) => {
if (password.match(re) != null) {
count += 1;
}
});
return count >= 3;
}
export function StrengthCheck(password: string) {
return lengthCheck(password) && comboCheck(password);
}

@ -101,6 +101,7 @@ angular.module('portainer.app').factory('Authentication', [
user.username = tokenPayload.username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
user.forceChangePassword = tokenPayload.forceChangePassword;
await setUserTheme();
}

@ -202,3 +202,18 @@ export function confirmChangePassword() {
},
});
}
export function confirmForceChangePassword() {
const box = bootbox.dialog({
message:
'Please update your password to a stronger password to continue using Portainer',
buttons: {
confirm: {
label: 'OK',
className: 'btn-primary',
},
},
});
applyBoxCSS(box);
}

@ -15,6 +15,7 @@ import {
confirmUpdate,
confirmWebEditorDiscard,
confirm,
confirmForceChangePassword,
} from './confirm';
import {
confirmContainerDeletion,
@ -58,5 +59,6 @@ export function ModalServiceAngular() {
selectRegistry,
confirmContainerDeletion,
confirmKubeconfigSelection,
confirmForceChangePassword,
};
}

@ -26,17 +26,12 @@
<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" ng-change="onNewPasswordChange()" id="new_password" />
</div>
</div>
</div>
<!-- !new-password-input -->
<div class="form-group">
<div class="col-sm-12">
<i ng-class="{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.newPassword.length >= 8]" aria-hidden="true"></i>
<span class="small text-muted">Your new password must be at least 8 characters long</span>
</div>
</div>
<!-- confirm-password-input -->
<div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label text-left">Confirm password</label>
@ -61,7 +56,7 @@
<button
type="submit"
class="btn btn-primary btn-sm"
ng-disabled="(AuthenticationMethod !== 1 && userID !== 1) || !formValues.currentPassword || formValues.newPassword.length < 8 || formValues.newPassword !== formValues.confirmPassword"
ng-disabled="(AuthenticationMethod !== 1 && userID !== 1) || !formValues.currentPassword || !passwordStrength || formValues.newPassword !== formValues.confirmPassword"
ng-click="updatePassword()"
>Update password</button
>
@ -75,6 +70,9 @@
</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>
@ -85,6 +83,7 @@
table-key="tokens"
order-by="Description"
remove-action="removeAction"
ui-can-exit="uiCanExit"
></access-tokens-datatable>
<theme-settings></theme-settings>
</div>

@ -1,3 +1,5 @@
import { MinPasswordLen, StrengthCheck } from 'Portainer/helpers/password';
angular.module('portainer.app').controller('AccountController', [
'$scope',
'$state',
@ -16,12 +18,16 @@ 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');
$scope.forceChangePassword = false;
$state.go('portainer.logout');
} catch (err) {
Notifications.error('Failure', err, err.msg);
@ -29,6 +35,21 @@ angular.module('portainer.app').controller('AccountController', [
}
};
$scope.onNewPasswordChange = function () {
$scope.passwordStrength = StrengthCheck($scope.formValues.newPassword);
};
this.uiCanExit = () => {
if ($scope.forceChangePassword) {
ModalService.confirmForceChangePassword();
}
return !$scope.forceChangePassword;
};
$scope.uiCanExit = () => {
return this.uiCanExit();
};
$scope.removeAction = (selectedTokens) => {
const msg = 'Do you want to remove the selected access token(s)? Any script or application using these tokens will no longer be able to invoke the Portainer API.';
@ -77,6 +98,7 @@ angular.module('portainer.app').controller('AccountController', [
async function initView() {
$scope.userID = Authentication.getUserDetails().ID;
$scope.forceChangePassword = Authentication.getUserDetails().forceChangePassword;
const data = await UserService.user($scope.userID);

@ -123,6 +123,10 @@ class AuthenticationController {
const endpoints = await this.EndpointService.endpoints(0, 1);
const isAdmin = this.Authentication.isAdmin();
if (this.Authentication.getUserDetails().forceChangePassword) {
return this.$state.go('portainer.account');
}
if (endpoints.value.length === 0 && isAdmin) {
return this.$state.go('portainer.wizard');
} else {

@ -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" auto-focus />
<input type="password" class="form-control" ng-model="formValues.Password" id="password" ng-change="onPasswordChange()" auto-focus />
</div>
</div>
<!-- !new-password-input -->
@ -63,11 +63,19 @@
<!-- !confirm-password-input -->
<!-- note -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
<i ng-class="{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.Password.length >= 8]" aria-hidden="true"></i>
The password must be at least 8 characters long
</span>
<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> -->
<p>
<i class="fa fa-times red-icon space-right" aria-hidden="true"></i>
<span>The password must be at least {{ MinPasswordLen }} characters long, including a combination of one character of three of the below:</span>
</p>
<ul>
<li class="ml-8"> Special characters </li>
<li class="ml-8"> Lower case characters </li>
<li class="ml-8"> Upper case characters </li>
<li class="ml-8"> Numeric characters </li>
</ul>
</div>
</div>
<!-- !note -->
@ -77,7 +85,7 @@
<button
type="submit"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || formValues.Password.length < 8 || formValues.Password !== formValues.ConfirmPassword"
ng-disabled="state.actionInProgress || !state.passwordStrength || formValues.Password !== formValues.ConfirmPassword"
ng-click="createAdminUser()"
button-spinner="state.actionInProgress"
>

@ -1,3 +1,5 @@
import { MinPasswordLen, StrengthCheck } from 'Portainer/helpers/password';
angular.module('portainer.app').controller('InitAdminController', [
'$scope',
'$state',
@ -25,10 +27,17 @@ 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;

@ -46,7 +46,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.Password" id="password" data-cy="user-passwordInput" />
<input type="password" class="form-control" ng-model="formValues.Password" id="password" ng-change="onPasswordChange()" data-cy="user-passwordInput" />
</div>
</div>
</div>
@ -68,6 +68,16 @@
</div>
</div>
<!-- !confirm-password-input -->
<!-- password-check-hint -->
<div class="form-group" ng-if="AuthenticationMethod === 1 && !state.passwordStrength">
<div class="col-sm-3 col-lg-2"></div>
<div class="col-sm-8">
<password-check-hint></password-check-hint>
</div>
</div>
<!-- ! password-check-hint -->
<!-- admin-checkbox -->
<div class="form-group" ng-if="isAdmin">
<div class="col-sm-12">
@ -120,7 +130,7 @@
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && formValues.Password === '') || (AuthenticationMethod === 1 && formValues.Password !== formValues.ConfirmPassword)"
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && !state.passwordStrength) || (AuthenticationMethod === 1 && formValues.Password !== formValues.ConfirmPassword)"
ng-click="addUser()"
button-spinner="state.actionInProgress"
data-cy="user-createUserButton"

@ -1,4 +1,5 @@
import _ from 'lodash-es';
import { StrengthCheck } from 'Portainer/helpers/password';
angular.module('portainer.app').controller('UsersController', [
'$q',
@ -16,6 +17,7 @@ angular.module('portainer.app').controller('UsersController', [
userCreationError: '',
validUsername: false,
actionInProgress: false,
passwordStrength: false,
};
$scope.formValues = {
@ -26,6 +28,10 @@ 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++) {

Loading…
Cancel
Save