Repository: gravitee-io/graviteeio-access-management
Pull request #1039 (2021-02-08 12:25:32)

feat(resetPassword): add option to delete passwordless devices after reset password action
closes gravitee-io/issues#4998


Patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java
index 74a4dba9a5..e6e8d3c2bf 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java
@@ -53,10 +53,7 @@ import io.gravitee.am.gateway.handler.root.resources.handler.error.ErrorHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.login.*;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.PasswordPolicyRequestParseHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.UserTokenRequestParseHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ForgotPasswordAccessHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ForgotPasswordSubmissionRequestParseHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordRequestParseHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordSubmissionRequestParseHandler;
+import io.gravitee.am.gateway.handler.root.resources.handler.user.password.*;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.register.RegisterAccessHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.register.RegisterConfirmationRequestParseHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.register.RegisterConfirmationSubmissionRequestParseHandler;
@@ -272,10 +269,13 @@ public class RootProvider extends AbstractService<ProtocolProvider> implements P
         rootRouter.route(HttpMethod.GET, "/resetPassword")
                 .handler(new ResetPasswordRequestParseHandler(userService))
                 .handler(clientRequestParseHandlerOptional)
+                .handler(userTokenRequestParseHandler)
+                .handler(new ResetPasswordOneTimeTokenHandler())
                 .handler(new ResetPasswordEndpoint(thymeleafTemplateEngine));
         rootRouter.route(HttpMethod.POST, "/resetPassword")
                 .handler(new ResetPasswordSubmissionRequestParseHandler())
                 .handler(userTokenRequestParseHandler)
+                .handler(new ResetPasswordOneTimeTokenHandler())
                 .handler(passwordPolicyRequestParseHandler)
                 .handler(new ResetPasswordSubmissionEndpoint(userService));
 
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java
index 4278edf43d..db91014fe3 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java
@@ -84,6 +84,7 @@ public class UserTokenRequestParseHandler extends UserRequestHandler {
             UserToken userToken = handler.result();
             context.put(ConstantKeys.USER_CONTEXT_KEY, userToken.getUser());
             context.put(ConstantKeys.CLIENT_CONTEXT_KEY, userToken.getClient());
+            context.put(ConstantKeys.TOKEN_CONTEXT_KEY, userToken.getToken());
             context.next();
         });
     }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandler.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandler.java
new file mode 100644
index 0000000000..70dd3f5bd0
--- /dev/null
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandler.java
@@ -0,0 +1,71 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.gateway.handler.root.resources.handler.user.password;
+
+import io.gravitee.am.common.jwt.JWT;
+import io.gravitee.am.gateway.handler.common.utils.ConstantKeys;
+import io.gravitee.am.gateway.handler.common.vertx.utils.RequestUtils;
+import io.gravitee.am.gateway.handler.root.resources.handler.user.UserRequestHandler;
+import io.gravitee.am.model.User;
+import io.vertx.reactivex.core.MultiMap;
+import io.vertx.reactivex.ext.web.RoutingContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+
+/**
+ * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public class ResetPasswordOneTimeTokenHandler extends UserRequestHandler {
+
+    private final static Logger logger = LoggerFactory.getLogger(ResetPasswordOneTimeTokenHandler.class);
+
+    @Override
+    public void handle(RoutingContext context) {
+        final String success = context.request().getParam(ConstantKeys.SUCCESS_PARAM_KEY);
+        final String warning = context.request().getParam(ConstantKeys.WARNING_PARAM_KEY);
+        final String error = context.request().getParam(ConstantKeys.ERROR_PARAM_KEY);
+        final User user = context.get(ConstantKeys.USER_CONTEXT_KEY);
+        final JWT jwt = context.get(ConstantKeys.TOKEN_CONTEXT_KEY);
+        final MultiMap queryParams = RequestUtils.getCleanedQueryParams(context.request());
+
+        // user has been redirected due to success, warn or error, continue
+        if (success != null || warning != null || error != null) {
+            context.next();
+            return;
+        }
+
+        // first time the user resets its password, continue
+        if (user.getLastPasswordReset() == null) {
+            context.next();
+            return;
+        }
+
+        // the current token has already been used for the action
+        // redirect user to the reset password page with an error
+        if (Instant.ofEpochSecond(jwt.getIat()).isBefore(user.getLastPasswordReset().toInstant())) {
+            logger.debug("Token has already been used for reset password action, skip it.");
+            queryParams.set(ConstantKeys.ERROR_PARAM_KEY, "invalid_token");
+            redirectToPage(context, queryParams);
+            return;
+        }
+
+        // continue
+        context.next();
+    }
+}
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java
index eab547af7c..1e800563c5 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java
@@ -37,6 +37,7 @@ import io.gravitee.am.model.factor.EnrolledFactor;
 import io.gravitee.am.model.oidc.Client;
 import io.gravitee.am.repository.management.api.search.LoginAttemptCriteria;
 import io.gravitee.am.service.AuditService;
+import io.gravitee.am.service.CredentialService;
 import io.gravitee.am.service.LoginAttemptService;
 import io.gravitee.am.service.exception.*;
 import io.gravitee.am.service.reporter.builder.AuditBuilder;
@@ -83,10 +84,19 @@ public class UserServiceImpl implements UserService {
     @Autowired
     private LoginAttemptService loginAttemptService;
 
+    @Autowired
+    private CredentialService credentialService;
+
     @Override
     public Maybe<UserToken> verifyToken(String token) {
         return Maybe.fromCallable(() -> jwtParser.parse(token))
-                .flatMap(jwt -> userService.findById(jwt.getSub()).zipWith(clientSource(jwt.getAud()), (user, optionalClient) -> new UserToken(user, optionalClient.orElse(null))));
+                .flatMap(jwt -> {
+                    return userService.findById(jwt.getSub())
+                            .zipWith(clientSource(jwt.getAud()),
+                                    (user, optionalClient) -> {
+                                return new UserToken(user, optionalClient.orElse(null), jwt);
+                            });
+                });
     }
 
     @Override
@@ -192,6 +202,9 @@ public class UserServiceImpl implements UserService {
 
     @Override
     public Single<ResetPasswordResponse> resetPassword(Client client, User user, io.gravitee.am.identityprovider.api.User principal) {
+        // get account settings
+        final AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
+
         // if user registration is not completed and force registration option is disabled throw invalid account exception
         if (user.isInactive() && !forceUserRegistration(domain, client)) {
             return Single.error(new AccountInactiveException("User needs to complete the activation process"));
@@ -235,7 +248,6 @@ public class UserServiceImpl implements UserService {
                     // additional information
                     extractAdditionalInformation(user, idpUser.getAdditionalInformation());
                     // set login information
-                    AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
                     if (accountSettings != null && accountSettings.isAutoLoginAfterResetPassword()) {
                         user.setLoggedAt(new Date());
                         user.setLoginsCount(user.getLoginsCount() + 1);
@@ -251,11 +263,16 @@ public class UserServiceImpl implements UserService {
                             .build();
                     return loginAttemptService.reset(criteria).andThen(Single.just(user1));
                 })
-                .flatMap(userService::enhance)
-                .map(user1 -> {
-                    AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
-                    return new ResetPasswordResponse(user1, accountSettings != null ? accountSettings.getRedirectUriAfterResetPassword() : null, accountSettings != null ? accountSettings.isAutoLoginAfterResetPassword() : false);
+                // delete passwordless devices
+                .flatMap(user1 -> {
+                    if (accountSettings != null && accountSettings.isDeletePasswordlessDevicesAfterResetPassword()) {
+                        return credentialService.deleteByUserId(user1.getReferenceType(), user1.getReferenceId(), user1.getId())
+                                .andThen(Single.just(user1));
+                    }
+                    return Single.just(user1);
                 })
+                .flatMap(userService::enhance)
+                .map(user1 -> new ResetPasswordResponse(user1, accountSettings != null ? accountSettings.getRedirectUriAfterResetPassword() : null, accountSettings != null ? accountSettings.isAutoLoginAfterResetPassword() : false))
                 .doOnSuccess(response -> auditService.report(AuditBuilder.builder(UserAuditBuilder.class).domain(domain.getId()).client(user.getClient()).principal(principal).type(EventType.USER_PASSWORD_RESET)))
                 .doOnError(throwable -> auditService.report(AuditBuilder.builder(UserAuditBuilder.class).domain(domain.getId()).client(user.getClient()).principal(principal).type(EventType.USER_PASSWORD_RESET).throwable(throwable)));
     }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java
index f41b807e70..9bb76321fd 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java
@@ -15,6 +15,7 @@
  */
 package io.gravitee.am.gateway.handler.root.service.user.model;
 
+import io.gravitee.am.common.jwt.JWT;
 import io.gravitee.am.model.oidc.Client;
 import io.gravitee.am.model.User;
 
@@ -26,10 +27,12 @@ public class UserToken {
 
     private final User user;
     private final Client client;
+    private final JWT token;
 
-    public UserToken(User user, Client client) {
+    public UserToken(User user, Client client, JWT token) {
         this.user = user;
         this.client = client;
+        this.token = token;
     }
 
     public User getUser() {
@@ -39,4 +42,8 @@ public class UserToken {
     public Client getClient() {
         return client;
     }
+
+    public JWT getToken() {
+        return token;
+    }
 }
diff --git a/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java b/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java
index 857a9256ef..8ba3b4ee0d 100644
--- a/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java
+++ b/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java
@@ -90,6 +90,11 @@ public class AccountSettings {
      */
     private String redirectUriAfterResetPassword;
 
+    /**
+     * Delete passwordless devices after reset password process
+     */
+    private boolean deletePasswordlessDevicesAfterResetPassword;
+
     public AccountSettings() {
     }
 
@@ -106,6 +111,7 @@ public class AccountSettings {
         this.dynamicUserRegistration = other.dynamicUserRegistration;
         this.autoLoginAfterResetPassword = other.autoLoginAfterResetPassword;
         this.redirectUriAfterResetPassword = other.redirectUriAfterResetPassword;
+        this.deletePasswordlessDevicesAfterResetPassword = other.deletePasswordlessDevicesAfterResetPassword;
     }
 
     public boolean isInherited() {
@@ -212,6 +218,14 @@ public class AccountSettings {
         this.redirectUriAfterResetPassword = redirectUriAfterResetPassword;
     }
 
+    public boolean isDeletePasswordlessDevicesAfterResetPassword() {
+        return deletePasswordlessDevicesAfterResetPassword;
+    }
+
+    public void setDeletePasswordlessDevicesAfterResetPassword(boolean deletePasswordlessDevicesAfterResetPassword) {
+        this.deletePasswordlessDevicesAfterResetPassword = deletePasswordlessDevicesAfterResetPassword;
+    }
+
     public static AccountSettings getInstance(Domain domain, Client client) {
         if (client == null) {
             return domain.getAccountSettings();
diff --git a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java
index 58d7a09dc4..4815132d5f 100644
--- a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java
@@ -18,6 +18,7 @@ package io.gravitee.am.repository.management.api;
 import io.gravitee.am.model.Credential;
 import io.gravitee.am.model.ReferenceType;
 import io.gravitee.am.repository.common.CrudRepository;
+import io.reactivex.Completable;
 import io.reactivex.Single;
 
 import java.util.List;
@@ -33,4 +34,6 @@ public interface CredentialRepository extends CrudRepository<Credential, String>
     Single<List<Credential>> findByUsername(ReferenceType referenceType, String referenceId, String username);
 
     Single<List<Credential>> findByCredentialId(ReferenceType referenceType, String referenceId, String credentialId);
+
+    Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId);
 }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java
index f2d3f53a7f..fb5661c33f 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java
@@ -116,6 +116,19 @@ public class MongoCredentialRepository extends AbstractManagementMongoRepository
     public Completable delete(String id) {
         return Completable.fromPublisher(credentialsCollection.deleteOne(eq(FIELD_ID, id)));
     }
+
+    @Override
+    public Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId) {
+        return Completable.fromPublisher(
+                credentialsCollection.deleteMany(
+                        and(
+                                eq(FIELD_REFERENCE_TYPE, referenceType.name()),
+                                eq(FIELD_REFERENCE_ID, referenceId),
+                                eq(FIELD_USER_ID, userId)
+                        )
+                ));
+    }
+
     private Credential convert(CredentialMongo credentialMongo) {
         if (credentialMongo == null) {
             return null;
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java
index 5ec355016b..f51272775a 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java
@@ -36,6 +36,7 @@ public class AccountSettingsMongo {
     private String defaultIdentityProviderForRegistration;
     private boolean autoLoginAfterResetPassword;
     private String redirectUriAfterResetPassword;
+    private boolean deletePasswordlessDevicesAfterResetPassword;
 
     public boolean isInherited() {
         return inherited;
@@ -141,6 +142,14 @@ public class AccountSettingsMongo {
         this.redirectUriAfterResetPassword = redirectUriAfterResetPassword;
     }
 
+    public boolean isDeletePasswordlessDevicesAfterResetPassword() {
+        return deletePasswordlessDevicesAfterResetPassword;
+    }
+
+    public void setDeletePasswordlessDevicesAfterResetPassword(boolean deletePasswordlessDevicesAfterResetPassword) {
+        this.deletePasswordlessDevicesAfterResetPassword = deletePasswordlessDevicesAfterResetPassword;
+    }
+
     public AccountSettings convert() {
         AccountSettings accountSettings = new AccountSettings();
         accountSettings.setInherited(isInherited());
@@ -156,6 +165,7 @@ public class AccountSettingsMongo {
         accountSettings.setAutoLoginAfterResetPassword(isAutoLoginAfterResetPassword());
         accountSettings.setRedirectUriAfterResetPassword(getRedirectUriAfterResetPassword());
         accountSettings.setSendRecoverAccountEmail(isSendRecoverAccountEmail());
+        accountSettings.setDeletePasswordlessDevicesAfterResetPassword(isDeletePasswordlessDevicesAfterResetPassword());
         return accountSettings;
     }
 
@@ -177,6 +187,7 @@ public class AccountSettingsMongo {
         accountSettingsMongo.setAutoLoginAfterResetPassword(accountSettings.isAutoLoginAfterResetPassword());
         accountSettingsMongo.setRedirectUriAfterResetPassword(accountSettings.getRedirectUriAfterResetPassword());
         accountSettingsMongo.setSendRecoverAccountEmail(accountSettings.isSendRecoverAccountEmail());
+        accountSettingsMongo.setDeletePasswordlessDevicesAfterResetPassword(accountSettings.isDeletePasswordlessDevicesAfterResetPassword());
         return accountSettingsMongo;
     }
 }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java
index 2eeeab7158..85dec4ba13 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java
@@ -44,4 +44,7 @@ public interface CredentialService {
     Completable update(ReferenceType referenceType, String referenceId, String credentialId, Credential credential);
 
     Completable delete(String id);
+
+    Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId);
+
 }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java
index d1003e1363..4efc5282a3 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java
@@ -152,4 +152,18 @@ public class CredentialServiceImpl implements CredentialService {
                             String.format("An error occurs while trying to delete credential: %s", id), ex));
                 });
     }
+
+    @Override
+    public Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId) {
+        LOGGER.debug("Delete credentials by {} {} and user id: {}", referenceType, referenceId, userId);
+        return credentialRepository.deleteByUserId(referenceType, referenceId, userId)
+                .onErrorResumeNext(ex -> {
+                    if (ex instanceof AbstractManagementException) {
+                        return Completable.error(ex);
+                    }
+                    LOGGER.error("An error has occurred while trying to delete credentials using {} {} and user id: {}", referenceType, referenceId, userId, ex);
+                    return Completable.error(new TechnicalManagementException(
+                            String.format("An error has occurred while trying to delete credentials using: %s %s and user id: %s", referenceType, referenceId, userId), ex));
+                });
+    }
 }
diff --git a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html
index c249bbde90..d369bffc53 100644
--- a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html
+++ b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html
@@ -28,10 +28,9 @@
   </div>
 
   <div *ngIf="!inheritMode || !accountSettings.inherited">
-
     <div class="gv-form-section">
       <div class="gv-form-section-title">
-        <h5>Security</h5>
+        <h5>Login</h5>
         <mat-divider></mat-divider>
       </div>
       <div>
@@ -83,28 +82,35 @@
             </mat-select>
           </mat-form-field>
         </div>
+        <div fxLayout="column" style="margin-top: 10px;">
+          <mat-slide-toggle
+            (change)="enableSendRecoverAccountEmail($event)"
+            [checked]="isSendRecoverAccountEmailEnabled()" [disabled]="readonly">
+            Send account recovery email
+          </mat-slide-toggle>
+          <mat-hint style="font-size: 75%;">Send an email if user account has been blocked.</mat-hint>
+        </div>
       </div>
     </div>
 
     <div class="gv-form-section">
       <div class="gv-form-section-title">
-        <h5>Management</h5>
+        <h5>User registration</h5>
         <mat-divider></mat-divider>
       </div>
       <div>
-        <mat-form-field>
-        <mat-select placeholder="Identity provider" name="defaultIdentityProviderForRegistration" [(ngModel)]="accountSettings.defaultIdentityProviderForRegistration" (ngModelChange)="updateModel()">
-          <mat-option>None</mat-option>
-          <mat-option *ngFor="let userProvider of userProviders" [value]="userProvider.id">{{userProvider.name}}</mat-option>
-        </mat-select>
-        <mat-hint>Default identity provider for user registration. The user will be stored in the selected identity provider instead of the default one.</mat-hint>
-      </mat-form-field>
-    </div>
-
-    <div class="account-settings-zone">
-      <div fxLayout="column">
+        <div fxLayout="column">
+          <mat-form-field appearance="outline">
+            <mat-select placeholder="Identity provider" name="defaultIdentityProviderForRegistration" [(ngModel)]="accountSettings.defaultIdentityProviderForRegistration" (ngModelChange)="updateModel()">
+              <mat-option>None</mat-option>
+              <mat-option *ngFor="let userProvider of userProviders" [value]="userProvider.id">{{userProvider.name}}</mat-option>
+            </mat-select>
+            <mat-hint>Default identity provider for user registration. The user will be stored in the selected identity provider instead of the default one.</mat-hint>
+          </mat-form-field>
+        </div>
+        <div fxLayout="column">
           <mat-slide-toggle
-              (change)="enableDynamicUserRegistration($event)"
+            (change)="enableDynamicUserRegistration($event)"
             [checked]="isDynamicUserRegistrationEnabled()">
             Dynamic user registration
           </mat-slide-toggle>
@@ -112,9 +118,9 @@
         </div>
         <div fxLayout="column" style="margin-top: 10px;">
           <mat-slide-toggle
-          (change)="enableCompleteRegistration($event)"
+            (change)="enableCompleteRegistration($event)"
             [checked]="isCompleteRegistrationEnabled()" [disabled]="readonly">
-            Complete user registration (Reset Password)
+            Complete user registration (reset password)
           </mat-slide-toggle>
           <mat-hint style="font-size: 75%;">If a user renewed his password without completing his registration, finalize the account. The default behavior is to display an error to the user.</mat-hint>
         </div>
@@ -125,31 +131,44 @@
             Auto login after user registration
           </mat-slide-toggle>
           <mat-hint style="font-size: 75%;">Automatically login user after registration process.</mat-hint>
+        </div>
+        <div fxLayout="column">
           <mat-form-field appearance="outline" floatLabel="always">
             <input matInput placeholder="Registration redirect URI" name="autoLoginRegistration" [(ngModel)]="accountSettings.redirectUriAfterRegistration" (ngModelChange)="updateModel()" [disabled]="readonly">
             <mat-hint>After complete its registration the user can be redirected to the specified URL. The default behavior is to display the registration confirmation page to the user.</mat-hint>
           </mat-form-field>
         </div>
-        <div fxLayout="column" style="margin-top: 20px;">
+      </div>
+    </div>
+
+    <div class="gv-form-section">
+      <div class="gv-form-section-title">
+        <h5>Reset password</h5>
+        <mat-divider></mat-divider>
+      </div>
+      <div>
+        <div fxLayout="column">
+          <mat-slide-toggle
+            (change)="enableDeletePasswordlessDevicesAfterResetPassword($event)"
+            [checked]="isDeletePasswordlessDevicesAfterResetPasswordEnabled()" [disabled]="readonly">
+            Delete passwordless devices
+          </mat-slide-toggle>
+          <mat-hint style="font-size: 75%;">Automatically delete passswordless devices after reset password process.</mat-hint>
+        </div>
+        <div fxLayout="column" style="margin-top: 10px;">
           <mat-slide-toggle
             (change)="enableAutoLoginAfterResetPassword($event)"
             [checked]="isAutoLoginAfterResetPasswordEnabled()" [disabled]="readonly">
             Auto login after reset password
           </mat-slide-toggle>
           <mat-hint style="font-size: 75%;">Automatically login user after reset password process.</mat-hint>
+        </div>
+        <div fxLayout="column">
           <mat-form-field appearance="outline" floatLabel="always">
             <input matInput placeholder="Reset password redirect URI" name="autoLoginResetPassword" [(ngModel)]="accountSettings.redirectUriAfterResetPassword" (ngModelChange)="updateModel()" [disabled]="readonly">
             <mat-hint>After reset its password the user can be redirected to the specified URL. The default behavior is to display the reset password page to the user.</mat-hint>
           </mat-form-field>
         </div>
-        <div fxLayout="column" style="margin-top: 10px;">
-          <mat-slide-toggle
-            (change)="enableSendRecoverAccountEmail($event)"
-            [checked]="isSendRecoverAccountEmailEnabled()" [disabled]="readonly">
-            Send account recovery email
-          </mat-slide-toggle>
-          <mat-hint style="font-size: 75%;">Send an email if user account has been blocked.</mat-hint>
-        </div>
       </div>
     </div>
   </div>
diff --git a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts
index 0c630d6d0e..2515c37020 100644
--- a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts
+++ b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts
@@ -144,6 +144,15 @@ export class AccountSettingsComponent implements OnInit, OnChanges {
     return this.accountSettings && this.accountSettings.sendRecoverAccountEmail;
   }
 
+  enableDeletePasswordlessDevicesAfterResetPassword(event) {
+    this.accountSettings.deletePasswordlessDevicesAfterResetPassword = event.checked;
+    this.formChanged = true;
+  }
+
+  isDeletePasswordlessDevicesAfterResetPasswordEnabled() {
+    return this.accountSettings && this.accountSettings.deletePasswordlessDevicesAfterResetPassword;
+  }
+
   updateModel() {
     this.formChanged = true;
   }


Test patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandlerTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandlerTest.java
new file mode 100644
index 0000000000..530bc52d94
--- /dev/null
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandlerTest.java
@@ -0,0 +1,117 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.gateway.handler.root.resources.handler.user.password;
+
+import io.gravitee.am.common.jwt.JWT;
+import io.gravitee.am.gateway.handler.common.utils.ConstantKeys;
+import io.gravitee.am.gateway.handler.common.vertx.RxWebTestBase;
+import io.gravitee.am.gateway.handler.common.vertx.web.handler.ErrorHandler;
+import io.gravitee.am.model.User;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.reactivex.core.buffer.Buffer;
+import io.vertx.reactivex.ext.web.handler.BodyHandler;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+/**
+ * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public class ResetPasswordOneTimeTokenHandlerTest extends RxWebTestBase {
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        router.route("/resetPassword")
+                .handler(BodyHandler.create())
+                .handler(new ResetPasswordOneTimeTokenHandler())
+                .handler(rc -> rc.response().end())
+                .failureHandler(new ErrorHandler());
+    }
+
+    @Test
+    public void shouldHandle_first_time_reset_password() throws Exception {
+        router.route().order(-1).handler(routingContext -> {
+            User endUser = new User();
+            endUser.setLastPasswordReset(null);
+            routingContext.put(ConstantKeys.USER_CONTEXT_KEY, endUser);
+            routingContext.next();
+        });
+
+        testRequest(HttpMethod.POST, "/resetPassword", req -> {
+            Buffer buffer = Buffer.buffer();
+            buffer.appendString("password=password");
+            req.headers().set("content-length", String.valueOf(buffer.length()));
+            req.headers().set("content-type", "application/x-www-form-urlencoded");
+            req.write(buffer);
+        },200, "OK", null);
+    }
+
+    @Test
+    public void shouldHandle_valid_token() throws Exception {
+        router.route().order(-1).handler(routingContext -> {
+            User endUser = new User();
+            endUser.setLastPasswordReset(new Date());
+
+            JWT token = new JWT();
+            token.setIat(Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond());
+
+            routingContext.put(ConstantKeys.TOKEN_CONTEXT_KEY, token);
+            routingContext.put(ConstantKeys.USER_CONTEXT_KEY, endUser);
+            routingContext.next();
+        });
+
+        testRequest(HttpMethod.POST, "/resetPassword", req -> {
+            Buffer buffer = Buffer.buffer();
+            buffer.appendString("password=password");
+            req.headers().set("content-length", String.valueOf(buffer.length()));
+            req.headers().set("content-type", "application/x-www-form-urlencoded");
+            req.write(buffer);
+        },200, "OK", null);
+    }
+
+    @Test
+    public void shouldNotHandle_invalid_token() throws Exception {
+        router.route().order(-1).handler(routingContext -> {
+            User endUser = new User();
+            endUser.setLastPasswordReset(new Date());
+
+            JWT token = new JWT();
+            token.setIat(Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond());
+
+            routingContext.put(ConstantKeys.TOKEN_CONTEXT_KEY, token);
+            routingContext.put(ConstantKeys.USER_CONTEXT_KEY, endUser);
+            routingContext.next();
+        });
+
+        testRequest(HttpMethod.POST, "/resetPassword", req -> {
+            Buffer buffer = Buffer.buffer();
+            buffer.appendString("password=password");
+            req.headers().set("content-length", String.valueOf(buffer.length()));
+            req.headers().set("content-type", "application/x-www-form-urlencoded");
+            req.write(buffer);
+        }, resp -> {
+            String location = resp.headers().get("location");
+            assertNotNull(location);
+            assertTrue(location.contains("error=invalid_token"));
+        },302, "Found", null);
+    }
+
+}
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java
index 4ef387d079..5253ace3fd 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java
@@ -24,6 +24,7 @@ import io.gravitee.am.model.Domain;
 import io.gravitee.am.model.User;
 import io.gravitee.am.model.account.AccountSettings;
 import io.gravitee.am.service.AuditService;
+import io.gravitee.am.service.CredentialService;
 import io.gravitee.am.service.LoginAttemptService;
 import io.gravitee.am.service.exception.UserInvalidException;
 import io.gravitee.am.service.exception.UserNotFoundException;
@@ -65,6 +66,9 @@ public class UserServiceTest {
     @Mock
     private LoginAttemptService loginAttemptService;
 
+    @Mock
+    private CredentialService credentialService;
+
     @Mock
     private AuditService auditService;
 
@@ -108,6 +112,8 @@ public class UserServiceTest {
         TestObserver testObserver = userService.resetPassword(client, user).test();
         testObserver.assertComplete();
         testObserver.assertNoErrors();
+
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -134,6 +140,8 @@ public class UserServiceTest {
         TestObserver testObserver = userService.resetPassword(client, user).test();
         testObserver.assertComplete();
         testObserver.assertNoErrors();
+
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -160,6 +168,8 @@ public class UserServiceTest {
         TestObserver testObserver = userService.resetPassword(client, user).test();
         testObserver.assertComplete();
         testObserver.assertNoErrors();
+
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -187,6 +197,7 @@ public class UserServiceTest {
         testObserver.assertComplete();
         testObserver.assertNoErrors();
         verify(userProvider, times(1)).create(any());
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -355,4 +366,36 @@ public class UserServiceTest {
         testObserver.assertError(UserNotFoundException.class);
     }
 
+    @Test
+    public void shouldResetPassword_delete_passwordless_devices() {
+        AccountSettings accountSettings = mock(AccountSettings.class);
+        when(accountSettings.isDeletePasswordlessDevicesAfterResetPassword()).thenReturn(true);
+
+        Client client = mock(Client.class);
+        when(client.getAccountSettings()).thenReturn(accountSettings);
+
+        User user = mock(User.class);
+        when(user.getUsername()).thenReturn("username");
+        when(user.isInactive()).thenReturn(false);
+        when(user.getSource()).thenReturn("default-idp");
+
+        io.gravitee.am.identityprovider.api.User idpUser = mock(io.gravitee.am.identityprovider.api.DefaultUser.class);
+        when(idpUser.getId()).thenReturn("idp-id");
+
+        UserProvider userProvider = mock(UserProvider.class);
+        when(userProvider.findByUsername(user.getUsername())).thenReturn(Maybe.just(idpUser));
+        when(userProvider.update(anyString(), any())).thenReturn(Single.just(idpUser));
+
+        when(identityProviderManager.getUserProvider(user.getSource())).thenReturn(Maybe.just(userProvider));
+        when(commonUserService.update(any())).thenReturn(Single.just(user));
+        when(commonUserService.enhance(any())).thenReturn(Single.just(user));
+        when(loginAttemptService.reset(any())).thenReturn(Completable.complete());
+        when(credentialService.deleteByUserId(any(), any(), any())).thenReturn(Completable.complete());
+
+        TestObserver testObserver = userService.resetPassword(client, user).test();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+
+        verify(credentialService, times(1)).deleteByUserId(any(), any(), any());
+    }
 }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java
index 93eea97b06..2ad2385df5 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java
@@ -178,4 +178,61 @@ public class MongoCredentialRepositoryTest extends AbstractManagementRepositoryT
         credentialRepository.findById(credentialCreated.getId()).test().assertEmpty();
     }
 
+    @Test
+    public void testDeleteByUser() throws TechnicalException {
+        // create credential
+        Credential credential = new Credential();
+        credential.setCredentialId("credentialId");
+        credential.setReferenceType(ReferenceType.DOMAIN);
+        credential.setReferenceId("domain-id");
+        credential.setUserId("user-id");
+        Credential credentialCreated = credentialRepository.create(credential).blockingGet();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver.awaitTerminalEvent();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+        testObserver.assertValue(l -> l.size() == 1 && l.get(0).getCredentialId().equals(credentialCreated.getCredentialId()));
+
+        // delete credential
+        TestObserver testObserver1 = credentialRepository.deleteByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver1.awaitTerminalEvent();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver2 = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver2.awaitTerminalEvent();
+        testObserver2.assertComplete();
+        testObserver2.assertNoErrors();
+        testObserver2.assertValue(l -> l.isEmpty());
+    }
+
+    @Test
+    public void testDeleteByUser_invalid_user() throws TechnicalException {
+        // create credential
+        Credential credential = new Credential();
+        credential.setCredentialId("credentialId");
+        credential.setReferenceType(ReferenceType.DOMAIN);
+        credential.setReferenceId("domain-id");
+        credential.setUserId("user-id");
+        Credential credentialCreated = credentialRepository.create(credential).blockingGet();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver.awaitTerminalEvent();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+        testObserver.assertValue(l -> l.size() == 1 && l.get(0).getCredentialId().equals(credentialCreated.getCredentialId()));
+
+        // delete credential
+        TestObserver testObserver1 = credentialRepository.deleteByUserId(ReferenceType.DOMAIN, "domain-id", "wrong-user-id").test();
+        testObserver1.awaitTerminalEvent();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver2 = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver2.awaitTerminalEvent();
+        testObserver2.assertComplete();
+        testObserver2.assertNoErrors();
+        testObserver.assertValue(l -> l.size() == 1 && l.get(0).getCredentialId().equals(credentialCreated.getCredentialId()));
+    }
 }