Skip to content

Commit

Permalink
AM-30: Gateway validation for password history
Browse files Browse the repository at this point in the history
  • Loading branch information
farmborough committed Nov 9, 2022
1 parent 2d780b5 commit 656b25c
Show file tree
Hide file tree
Showing 36 changed files with 1,216 additions and 280 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public interface EventType {
String FORGOT_PASSWORD_EMAIL_SENT = "FORGOT_PASSWORD_EMAIL_SENT";
String RESET_PASSWORD_EMAIL_SENT = "RESET_PASSWORD_EMAIL_SENT";
String BLOCKED_ACCOUNT_EMAIL_SENT = "BLOCKED_ACCOUNT_EMAIL_SENT";
String PASSWORD_HISTORY_CREATED = "PASSWORD_HISTORY_CREATED";

/**
* ----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public interface ConstantKeys {
// Forgot Password
String FORGOT_PASSWORD_FIELDS_KEY = "forgotPwdFormFields";
String FORGOT_PASSWORD_CONFIRM = "forgot_password_confirm";
String PASSWORD_HISTORY = "passwordHistory";

// Login keys.
String USER_LOGIN_COMPLETED_KEY = "userLoginCompleted";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,15 @@
<artifactId>vertx-auth-shiro</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
import io.gravitee.am.gateway.handler.root.resources.handler.user.activity.UserActivityHandler;
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.PasswordHistoryHandler;
import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordOneTimeTokenHandler;
import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordRequestParseHandler;
import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordSubmissionRequestParseHandler;
Expand All @@ -104,6 +105,7 @@
import io.gravitee.am.service.PasswordService;
import io.gravitee.am.service.UserActivityService;
import io.gravitee.am.service.i18n.GraviteeMessageResolver;
import io.gravitee.am.service.impl.PasswordHistoryService;
import io.gravitee.common.service.AbstractService;
import io.vertx.core.Handler;
import io.vertx.core.http.HttpMethod;
Expand Down Expand Up @@ -264,6 +266,9 @@ public class RootProvider extends AbstractService<ProtocolProvider> implements P
@Autowired
private RateLimiterService rateLimiterService;

@Autowired
private PasswordHistoryService passwordHistoryService;

@Override
protected void doStart() throws Exception {
super.doStart();
Expand Down Expand Up @@ -527,6 +532,10 @@ protected void doStart() throws Exception {
rootRouter.route(PATH_RESET_PASSWORD)
.failureHandler(resetPasswordFailureHandler);

rootRouter.route(HttpMethod.POST, "/passwordHistory")
.handler(clientRequestParseHandlerOptional)
.handler(new PasswordHistoryHandler(passwordHistoryService, userService, domain));

// error route
rootRouter.route(HttpMethod.GET, PATH_ERROR)
.handler(new ErrorEndpoint(domain, thymeleafTemplateEngine, clientSyncService, jwtService));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
import java.util.HashMap;
import java.util.Map;

import static io.gravitee.am.common.utils.ConstantKeys.PASSWORD_HISTORY;
import static io.gravitee.am.gateway.handler.common.utils.ThymeleafDataHelper.generateData;
import static io.gravitee.am.gateway.handler.common.vertx.utils.UriBuilderRequest.CONTEXT_PATH;

/**
* @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
Expand Down Expand Up @@ -76,6 +78,7 @@ public void handle(RoutingContext routingContext) {

final Map<String, String> actionParams = (client != null) ? Map.of(Parameters.CLIENT_ID, client.getClientId()) : Map.of();
routingContext.put(ConstantKeys.ACTION_KEY, UriBuilderRequest.resolveProxyRequest(routingContext.request(), routingContext.request().path(), actionParams));
routingContext.put(PASSWORD_HISTORY, UriBuilderRequest.resolveProxyRequest(routingContext.request(), routingContext.get(CONTEXT_PATH) + "/passwordHistory", actionParams, true));

// render the reset password page
this.renderPage(routingContext, generateData(routingContext, domain, client), client, logger, "Unable to render reset password page");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 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.utils.ConstantKeys;
import io.gravitee.am.gateway.handler.root.service.user.UserService;
import io.gravitee.am.model.Domain;
import io.gravitee.am.model.PasswordSettings;
import io.gravitee.am.model.ReferenceType;
import io.gravitee.am.model.oidc.Client;
import io.gravitee.am.service.exception.PasswordHistoryException;
import io.gravitee.am.service.impl.PasswordHistoryService;
import io.gravitee.common.http.HttpStatusCode;
import io.vertx.core.Handler;
import io.vertx.reactivex.ext.web.RoutingContext;

/**
* Checks a password against a user's history of passwords and returns either a
* 200 ok or 400 failure if the password is already in the history.
*/
public class PasswordHistoryHandler implements Handler<RoutingContext> {

private final PasswordHistoryService passwordHistoryService;
private final UserService userService;
private final Domain domain;

public PasswordHistoryHandler(PasswordHistoryService passwordHistoryService, UserService userService, Domain domain) {
this.passwordHistoryService = passwordHistoryService;
this.userService = userService;
this.domain = domain;
}

@SuppressWarnings("ResultOfMethodCallIgnored")
@Override
public void handle(RoutingContext context) {
var accessToken = context.request().getFormAttribute(ConstantKeys.TOKEN_CONTEXT_KEY);
var password = context.request().getFormAttribute(ConstantKeys.PASSWORD_PARAM_KEY);
userService.verifyToken(accessToken)
.flatMapSingle(userToken -> {
var user = userToken.getUser();
return passwordHistoryService
.passwordAlreadyUsed(ReferenceType.DOMAIN, domain.getId(), user.getId(), password, getPasswordSettings(context));

})
.doOnError(throwable -> context.fail(HttpStatusCode.INTERNAL_SERVER_ERROR_500, throwable))
.subscribe(passwordUsed -> {
if (Boolean.TRUE.equals(passwordUsed)) {
context.response().setStatusCode(HttpStatusCode.BAD_REQUEST_400).end();
} else {
context.response().setStatusCode(HttpStatusCode.OK_200).end();
}
});

}

private PasswordSettings getPasswordSettings(RoutingContext context) {
Client client = context.get(ConstantKeys.CLIENT_CONTEXT_KEY);
return PasswordSettings.getInstance(client, domain).orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import io.gravitee.am.service.exception.UserInvalidException;
import io.gravitee.am.service.exception.UserNotFoundException;
import io.gravitee.am.service.exception.UserProviderNotFoundException;
import io.gravitee.am.service.impl.PasswordHistoryService;
import io.gravitee.am.service.reporter.builder.AuditBuilder;
import io.gravitee.am.service.reporter.builder.management.UserAuditBuilder;
import io.gravitee.am.service.validators.email.EmailValidator;
Expand All @@ -63,13 +64,14 @@
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.util.*;
import java.util.Map.Entry;

import static io.gravitee.am.model.ReferenceType.DOMAIN;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static java.util.Map.entry;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
Expand All @@ -81,7 +83,6 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

/**
* @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
Expand Down Expand Up @@ -133,6 +134,9 @@ public class UserServiceImpl implements UserService {
@Autowired
private EmailValidator emailValidator;

@Autowired
private PasswordHistoryService passwordHistoryService;

@Override
public Maybe<UserToken> verifyToken(String token) {
return Maybe.fromCallable(() -> jwtParser.parse(token))
Expand Down Expand Up @@ -172,7 +176,7 @@ public Single<RegistrationResponse> register(Client client, User user, io.gravit
AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
final String source = (accountSettings != null && accountSettings.getDefaultIdentityProviderForRegistration() != null) ? accountSettings.getDefaultIdentityProviderForRegistration()
: (user.getSource() == null ? DEFAULT_IDP_PREFIX + domain.getId() : user.getSource());

final var rawPassword = user.getPassword();
// validate user and then check user uniqueness
return userValidator.validate(user)
.andThen(userService.findByDomainAndUsernameAndSource(domain.getId(), user.getUsername(), source)
Expand Down Expand Up @@ -211,7 +215,10 @@ public Single<RegistrationResponse> register(Client client, User user, io.gravit
}
return userService.create(user);
})
.flatMap(userService::enhance)
.flatMap( amUser -> {
createPasswordHistory(client, amUser, rawPassword, principal);
return userService.enhance(amUser);
})
.map(user1 -> new RegistrationResponse(user1, accountSettings != null ? accountSettings.getRedirectUriAfterRegistration() : null, accountSettings != null && accountSettings.isAutoLoginAfterRegistration()))
.doOnSuccess(registrationResponse -> {
// reload principal
Expand All @@ -225,6 +232,7 @@ public Single<RegistrationResponse> register(Client client, User user, io.gravit
@Override
public Single<RegistrationResponse> confirmRegistration(Client client, User user, io.gravitee.am.identityprovider.api.User
principal) {
final var rawPassword = user.getPassword();
// user has completed his account, add it to the idp
return identityProviderManager.getUserProvider(user.getSource())
.switchIfEmpty(Maybe.error(new UserProviderNotFoundException(user.getSource())))
Expand Down Expand Up @@ -256,7 +264,10 @@ public Single<RegistrationResponse> confirmRegistration(Client client, User user
}
return userService.update(user);
})
.flatMap(userService::enhance)
.flatMap( amUser -> {
createPasswordHistory(client, amUser, rawPassword, principal);
return userService.enhance(amUser);
})
.map(user1 -> {
AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
return new RegistrationResponse(user1, accountSettings != null ? accountSettings.getRedirectUriAfterRegistration() : null, accountSettings != null ? accountSettings.isAutoLoginAfterRegistration() : false);
Expand All @@ -266,13 +277,14 @@ public Single<RegistrationResponse> confirmRegistration(Client client, User user

}

@SuppressWarnings({"ReactiveStreamsUnusedPublisher", "ResultOfMethodCallIgnored"})
@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(accountSettings)) {
if (TRUE.equals(user.isInactive()) && !forceUserRegistration(accountSettings)) {
return Single.error(new AccountInactiveException("User needs to complete the activation process"));
}

Expand All @@ -286,13 +298,14 @@ public Single<ResetPasswordResponse> resetPassword(Client client, User user, io.
// see https://github.com/gravitee-io/issues/issues/8407
return userProvider.findByUsername(user.getUsername())
.switchIfEmpty(Maybe.error(new UserNotFoundException(user.getUsername())))
.flatMapSingle(idpUser -> userProvider.updatePassword(idpUser, user.getPassword()))
.flatMapSingle(idpUser -> passwordHistoryService
.addPasswordToHistory(DOMAIN, domain.getId(), user, user.getPassword() , principal, getPasswordSettings(client))
.switchIfEmpty(Maybe.just(new PasswordHistory()))
.flatMapSingle(passwordHistory -> userProvider.updatePassword(idpUser, user.getPassword())))
.onErrorResumeNext(ex -> {
if (ex instanceof UserNotFoundException) {
if (ex instanceof UserNotFoundException && forceUserRegistration(accountSettings)) {
// idp user not found, create its account, only if force registration is enabled
if (forceUserRegistration(accountSettings)) {
return userProvider.create(convert(user));
}
return userProvider.create(convert(user));
}
return Single.error(ex);
});
Expand All @@ -301,7 +314,7 @@ public Single<ResetPasswordResponse> resetPassword(Client client, User user, io.
.flatMap(idpUser -> {
// update 'users' collection for management and audit purpose
// if user was in pre-registration mode, end the registration process
if (user.isPreRegistration()) {
if (TRUE.equals(user.isPreRegistration())) {
user.setRegistrationCompleted(true);
user.setEnabled(true);
}
Expand Down Expand Up @@ -640,7 +653,20 @@ private User enhanceUser(User user, io.gravitee.am.identityprovider.api.User idp
return user;
}

@SuppressWarnings("ResultOfMethodCallIgnored")
private void createPasswordHistory(Client client, User user, String rawPassword, io.gravitee.am.identityprovider.api.User principal) {
passwordHistoryService
.addPasswordToHistory(DOMAIN, domain.getId(), user, rawPassword , principal, getPasswordSettings(client))
.subscribe(passwordHistory -> logger.debug("Created password history for user {}", user.getUsername()),
throwable -> logger.debug("Failed to create password history", throwable));
}

private PasswordSettings getPasswordSettings(Client client) {
return PasswordSettings.getInstance(client, domain).orElse(null);
}

private class UserAuthentication {

private final io.gravitee.am.identityprovider.api.User user;
private final String source;

Expand All @@ -656,6 +682,6 @@ public io.gravitee.am.identityprovider.api.User getUser() {
public String getSource() {
return source;
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const mixedCase = document.getElementById("mixedCase");
const maxConsecutiveLetters = document.getElementById("maxConsecutiveLetters");
const excludeUserProfileInfoInPassword = document.getElementById("excludeUserProfileInfoInPassword");
const matchPasswords = document.getElementById("matchPasswords");
const excludePasswordsInHistory = document.getElementById("excludePasswordsInHistory");

function validatePassword() {
if (passwordSettings == null) {
Expand Down Expand Up @@ -76,6 +77,23 @@ function validatePassword() {
return isMinLengthOk && isIncludeNumbersOk && isIncludeSpecialCharactersOk && isLettersInMixedCaseOk && isMaxConsecutiveLettersOk && isExcludeUserProfileInfoInPasswordOk;
}

const checkPasswordHistory = () => {
let passwordPromise = Promise.resolve();
const isResetForm = document.getElementById("token");
if (isResetForm && passwordSettings.passwordHistoryEnabled) {
const token = document.getElementById("token").getAttribute("value");
const csrfToken = document.getElementById("csrfToken").getAttribute("value");
const formData = new FormData();
formData.append('token', token);
formData.append('csrfToken', csrfToken);
formData.append('password', passwordInput.value);
passwordPromise = fetch(passwordHistory, {
method: 'POST',
body: formData
});
}
return passwordPromise;
};

/**
*
Expand Down Expand Up @@ -132,8 +150,16 @@ function disableSubmitButton(){
}

function enableSubmitButton(){
submitBtn.disabled = false;
submitBtn.classList.remove("button-disabled");
checkPasswordHistory().then(response => {
const passwordNotInHistory = response ? response.ok : true;
if (excludePasswordsInHistory) {
validateMessageElement(excludePasswordsInHistory, passwordNotInHistory);
}
if (passwordNotInHistory) {
submitBtn.disabled = false;
submitBtn.classList.remove("button-disabled");
}
});
}

function toggleSubmit(element) {
Expand Down
Loading

0 comments on commit 656b25c

Please sign in to comment.