diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 37c895b44..39a792f72 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -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, } } diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 8266eb0db..22cd243a2 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -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) } diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go index d4a8f1084..8e3500800 100644 --- a/api/http/handler/users/admin_init.go +++ b/api/http/handler/users/admin_init.go @@ -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, diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index 165222f28..51024d8e8 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -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} diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go index 99c92ebab..e569d0ab5 100644 --- a/api/http/handler/users/user_update_password.go +++ b/api/http/handler/users/user_update_password.go @@ -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} diff --git a/api/internal/passwordutils/strengthCheck.go b/api/internal/passwordutils/strengthCheck.go new file mode 100644 index 000000000..99d5ca473 --- /dev/null +++ b/api/internal/passwordutils/strengthCheck.go @@ -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) +} diff --git a/api/internal/passwordutils/strengthCheck_test.go b/api/internal/passwordutils/strengthCheck_test.go new file mode 100644 index 000000000..1ee45461a --- /dev/null +++ b/api/internal/passwordutils/strengthCheck_test.go @@ -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) + } + }) + } +} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 523527a9c..77b9fc279 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -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(), diff --git a/api/portainer.go b/api/portainer.go index a78ffc321..aed012f26 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 diff --git a/app/assets/css/app.css b/app/assets/css/app.css index f014936e2..76afc86a1 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -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; diff --git a/app/portainer/components/PasswordCheckHint.tsx b/app/portainer/components/PasswordCheckHint.tsx new file mode 100644 index 000000000..4db39f946 --- /dev/null +++ b/app/portainer/components/PasswordCheckHint.tsx @@ -0,0 +1,59 @@ +import { react2angular } from '@/react-tools/react2angular'; + +import { MinPasswordLen } from '../helpers/password'; + +function PasswordCombination() { + return ( + + ); +} + +export function ForcePasswordUpdateHint() { + return ( +
+

+