diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index dfa264350..cec78759c 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -3,6 +3,7 @@ package users import ( "errors" "net/http" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -99,6 +100,7 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure} } + user.TokenIssueAt = time.Now().Unix() } if payload.Role != 0 { @@ -116,6 +118,5 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http // remove all of the users persisted API keys handler.apiKeyService.InvalidateUserKeyCache(user.ID) - return response.JSON(w, user) } diff --git a/api/http/handler/users/user_update_password.go b/api/http/handler/users/user_update_password.go index eb97581fb..99c92ebab 100644 --- a/api/http/handler/users/user_update_password.go +++ b/api/http/handler/users/user_update_password.go @@ -3,6 +3,7 @@ package users import ( "errors" "net/http" + "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -85,6 +86,8 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure} } + user.TokenIssueAt = time.Now().Unix() + err = handler.DataStore.User().UpdateUser(user.ID, user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 05678c433..283bff1b1 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -121,6 +121,14 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, if err == nil && parsedToken != nil { if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid { + + user, err := service.dataStore.User().User(portainer.UserID(cl.UserID)) + if err != nil { + return nil, errInvalidJWTToken + } + if user.TokenIssueAt > cl.StandardClaims.IssuedAt { + return nil, errInvalidJWTToken + } return &portainer.TokenData{ ID: portainer.UserID(cl.UserID), Username: cl.Username, @@ -162,6 +170,7 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt Scope: scope, StandardClaims: jwt.StandardClaims{ ExpiresAt: expiresAt, + IssuedAt: time.Now().Unix(), }, } diff --git a/api/portainer.go b/api/portainer.go index aded693f5..66f1125b2 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1103,7 +1103,8 @@ type ( // User Theme UserTheme string `example:"dark"` // User role (1 for administrator account and 2 for regular account) - Role UserRole `json:"Role" example:"1"` + Role UserRole `json:"Role" example:"1"` + TokenIssueAt int64 `json:"TokenIssueAt" example:"1"` // Deprecated fields // Deprecated in DBVersion == 25 diff --git a/app/portainer/services/modal.service/confirm.ts b/app/portainer/services/modal.service/confirm.ts index e56fa2dd7..592fb25bd 100644 --- a/app/portainer/services/modal.service/confirm.ts +++ b/app/portainer/services/modal.service/confirm.ts @@ -218,3 +218,17 @@ export function confirmImageExport(callback: ConfirmCallback) { callback, }); } + +export function confirmChangePassword() { + return confirmAsync({ + title: 'Are you sure?', + message: + 'You will be logged out after the password change. Do you want to change your password?', + buttons: { + confirm: { + label: 'Change', + className: 'btn-primary', + }, + }, + }); +} diff --git a/app/portainer/services/modal.service/index.ts b/app/portainer/services/modal.service/index.ts index 95ff58ff1..8653e3f68 100644 --- a/app/portainer/services/modal.service/index.ts +++ b/app/portainer/services/modal.service/index.ts @@ -10,6 +10,7 @@ import { confirmDetachment, confirmDeletionAsync, confirmEndpointSnapshot, + confirmChangePassword, confirmImageExport, confirmImageForceRemoval, confirmRedeploy, @@ -53,6 +54,7 @@ export function ModalServiceAngular() { confirmDeletionAsync, confirmContainerRecreation, confirmEndpointSnapshot, + confirmChangePassword, confirmImageExport, confirmServiceForceUpdate, selectRegistry, diff --git a/app/portainer/views/account/accountController.js b/app/portainer/views/account/accountController.js index 1e4ef2969..ca2795de9 100644 --- a/app/portainer/views/account/accountController.js +++ b/app/portainer/views/account/accountController.js @@ -16,15 +16,17 @@ angular.module('portainer.app').controller('AccountController', [ userTheme: '', }; - $scope.updatePassword = function () { - UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword) - .then(function success() { + $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'); - $state.reload(); - }) - .catch(function error(err) { + $state.go('portainer.logout'); + } catch (err) { Notifications.error('Failure', err, err.msg); - }); + } + } }; $scope.removeAction = (selectedTokens) => { diff --git a/app/portainer/views/users/edit/userController.js b/app/portainer/views/users/edit/userController.js index 12aead65d..d0fec7a50 100644 --- a/app/portainer/views/users/edit/userController.js +++ b/app/portainer/views/users/edit/userController.js @@ -63,11 +63,21 @@ angular.module('portainer.app').controller('UserController', [ }); }; - $scope.updatePassword = function () { + $scope.updatePassword = async function () { + const isCurrentUser = Authentication.getUserDetails().ID === $scope.user.Id; + const confirmed = !isCurrentUser || (await ModalService.confirmChangePassword()); + if (!confirmed) { + return; + } UserService.updateUser($scope.user.Id, { password: $scope.formValues.newPassword }) .then(function success() { Notifications.success('Password successfully updated'); - $state.reload(); + + if (isCurrentUser) { + $state.go('portainer.logout'); + } else { + $state.reload(); + } }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to update user password');