KEYCLOAK-8125
This commit is contained in:
parent
6564cebc0f
commit
0cb6053699
10 changed files with 303 additions and 134 deletions
|
@ -25,6 +25,7 @@ import org.keycloak.credential.CredentialInput;
|
|||
import org.keycloak.credential.hash.PasswordHashProvider;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.ModelDuplicateException;
|
||||
import org.keycloak.models.PasswordPolicy;
|
||||
import org.keycloak.models.UserCredentialModel;
|
||||
|
@ -56,25 +57,19 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
|||
|
||||
}
|
||||
|
||||
protected Response invalidUser(AuthenticationFlowContext context) {
|
||||
return context.form()
|
||||
.setError(Messages.INVALID_USER)
|
||||
.createLogin();
|
||||
protected Response challenge(AuthenticationFlowContext context, String error) {
|
||||
LoginFormsProvider form = context.form();
|
||||
if (error != null) form.setError(error);
|
||||
|
||||
return createLoginForm(form);
|
||||
}
|
||||
|
||||
protected Response disabledUser(AuthenticationFlowContext context) {
|
||||
return context.form()
|
||||
.setError(Messages.ACCOUNT_DISABLED).createLogin();
|
||||
protected Response createLoginForm(LoginFormsProvider form) {
|
||||
return form.createLogin();
|
||||
}
|
||||
|
||||
protected Response temporarilyDisabledUser(AuthenticationFlowContext context) {
|
||||
return context.form()
|
||||
.setError(Messages.INVALID_USER).createLogin();
|
||||
}
|
||||
|
||||
protected Response invalidCredentials(AuthenticationFlowContext context) {
|
||||
return context.form()
|
||||
.setError(Messages.INVALID_USER).createLogin();
|
||||
protected String tempDisabledError() {
|
||||
return Messages.INVALID_USER;
|
||||
}
|
||||
|
||||
protected Response setDuplicateUserChallenge(AuthenticationFlowContext context, String eventError, String loginFormError, AuthenticationFlowError authenticatorError) {
|
||||
|
@ -112,7 +107,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
|||
if (user == null) {
|
||||
dummyHash(context);
|
||||
context.getEvent().error(Errors.USER_NOT_FOUND);
|
||||
Response challengeResponse = invalidUser(context);
|
||||
Response challengeResponse = challenge(context, Messages.INVALID_USER);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse);
|
||||
return true;
|
||||
}
|
||||
|
@ -123,7 +118,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
|||
if (!user.isEnabled()) {
|
||||
context.getEvent().user(user);
|
||||
context.getEvent().error(Errors.USER_DISABLED);
|
||||
Response challengeResponse = disabledUser(context);
|
||||
Response challengeResponse = challenge(context, Messages.ACCOUNT_DISABLED);
|
||||
// this is not a failure so don't call failureChallenge.
|
||||
//context.failureChallenge(AuthenticationFlowError.USER_DISABLED, challengeResponse);
|
||||
context.forceChallenge(challengeResponse);
|
||||
|
@ -137,7 +132,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
|||
String username = inputData.getFirst(AuthenticationManager.FORM_USERNAME);
|
||||
if (username == null) {
|
||||
context.getEvent().error(Errors.USER_NOT_FOUND);
|
||||
Response challengeResponse = invalidUser(context);
|
||||
Response challengeResponse = challenge(context, Messages.INVALID_USER);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse);
|
||||
return false;
|
||||
}
|
||||
|
@ -200,19 +195,19 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
|||
} else {
|
||||
context.getEvent().user(user);
|
||||
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);
|
||||
Response challengeResponse = invalidCredentials(context);
|
||||
Response challengeResponse = challenge(context, Messages.INVALID_USER);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challengeResponse);
|
||||
context.clearUser();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTemporarilyDisabledByBruteForce(AuthenticationFlowContext context, UserModel user) {
|
||||
protected boolean isTemporarilyDisabledByBruteForce(AuthenticationFlowContext context, UserModel user) {
|
||||
if (context.getRealm().isBruteForceProtected()) {
|
||||
if (context.getProtector().isTemporarilyDisabled(context.getSession(), context.getRealm(), user)) {
|
||||
context.getEvent().user(user);
|
||||
context.getEvent().error(Errors.USER_TEMPORARILY_DISABLED);
|
||||
Response challengeResponse = temporarilyDisabledUser(context);
|
||||
Response challengeResponse = challenge(context, tempDisabledError());
|
||||
// this is not a failure so don't call failureChallenge.
|
||||
//context.failureChallenge(AuthenticationFlowError.USER_TEMPORARILY_DISABLED, challengeResponse);
|
||||
context.forceChallenge(challengeResponse);
|
||||
|
@ -221,5 +216,4 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -54,16 +54,23 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
context.resetFlow();
|
||||
return;
|
||||
}
|
||||
|
||||
UserModel userModel = context.getUser();
|
||||
if (!enabledUser(context, userModel)) {
|
||||
// error in context is set in enabledUser/isTemporarilyDisabledByBruteForce
|
||||
return;
|
||||
}
|
||||
|
||||
String password = inputData.getFirst(CredentialRepresentation.TOTP);
|
||||
if (password == null) {
|
||||
Response challengeResponse = challenge(context, null);
|
||||
context.challenge(challengeResponse);
|
||||
return;
|
||||
}
|
||||
boolean valid = context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(),
|
||||
boolean valid = context.getSession().userCredentialManager().isValid(context.getRealm(), userModel,
|
||||
UserCredentialModel.otp(context.getRealm().getOTPPolicy().getType(), password));
|
||||
if (!valid) {
|
||||
context.getEvent().user(context.getUser())
|
||||
context.getEvent().user(userModel)
|
||||
.error(Errors.INVALID_USER_CREDENTIALS);
|
||||
Response challengeResponse = challenge(context, Messages.INVALID_TOTP);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challengeResponse);
|
||||
|
@ -77,11 +84,14 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
|
|||
return true;
|
||||
}
|
||||
|
||||
protected Response challenge(AuthenticationFlowContext context, String error) {
|
||||
LoginFormsProvider forms = context.form();
|
||||
if (error != null) forms.setError(error);
|
||||
@Override
|
||||
protected String tempDisabledError() {
|
||||
return Messages.INVALID_TOTP;
|
||||
}
|
||||
|
||||
return forms.createLoginTotp();
|
||||
@Override
|
||||
protected Response createLoginForm(LoginFormsProvider form) {
|
||||
return form.createLoginTotp();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -49,14 +49,14 @@ public class BasicAuthAuthenticator extends AbstractUsernameFormAuthenticator im
|
|||
String authorizationHeader = getAuthorizationHeader(context);
|
||||
|
||||
if (authorizationHeader == null) {
|
||||
context.challenge(challengeResponse(context));
|
||||
context.challenge(challenge(context, null));
|
||||
return;
|
||||
}
|
||||
|
||||
String[] challenge = getChallenge(authorizationHeader);
|
||||
|
||||
if (challenge == null) {
|
||||
context.challenge(challengeResponse(context));
|
||||
context.challenge(challenge(context, null));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -64,9 +64,6 @@ public class BasicAuthAuthenticator extends AbstractUsernameFormAuthenticator im
|
|||
context.success();
|
||||
return;
|
||||
}
|
||||
|
||||
context.setUser(null);
|
||||
context.challenge(challengeResponse(context));
|
||||
}
|
||||
|
||||
protected boolean onAuthenticate(AuthenticationFlowContext context, String[] challenge) {
|
||||
|
@ -104,29 +101,14 @@ public class BasicAuthAuthenticator extends AbstractUsernameFormAuthenticator im
|
|||
return challenge;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response invalidUser(AuthenticationFlowContext context) {
|
||||
return challengeResponse(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response disabledUser(AuthenticationFlowContext context) {
|
||||
return challengeResponse(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response temporarilyDisabledUser(AuthenticationFlowContext context) {
|
||||
return challengeResponse(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response invalidCredentials(AuthenticationFlowContext context) {
|
||||
return challengeResponse(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response setDuplicateUserChallenge(AuthenticationFlowContext context, String eventError, String loginFormError, AuthenticationFlowError authenticatorError) {
|
||||
return challengeResponse(context);
|
||||
return challenge(context, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response challenge(AuthenticationFlowContext context, String error) {
|
||||
return Response.status(401).header(HttpHeaders.WWW_AUTHENTICATE, getHeader(context)).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -148,10 +130,6 @@ public class BasicAuthAuthenticator extends AbstractUsernameFormAuthenticator im
|
|||
|
||||
}
|
||||
|
||||
private Response challengeResponse(AuthenticationFlowContext context) {
|
||||
return Response.status(401).header(HttpHeaders.WWW_AUTHENTICATE, getHeader(context)).build();
|
||||
}
|
||||
|
||||
private String getHeader(AuthenticationFlowContext context) {
|
||||
return "Basic realm=\"" + context.getRealm().getName() + "\"";
|
||||
}
|
||||
|
|
|
@ -17,12 +17,19 @@
|
|||
package org.keycloak.authentication.authenticators.challenge;
|
||||
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.AuthenticationFlowError;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OTPPolicy;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserCredentialModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
|
||||
import javax.ws.rs.core.Response;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
|
@ -44,7 +51,7 @@ public class BasicAuthOTPAuthenticator extends BasicAuthAuthenticator implements
|
|||
password = password.substring(0, password.length() - otpLength);
|
||||
|
||||
if (checkUsernameAndPassword(context, username, password)) {
|
||||
String otp = password.substring(password.length() - otpLength);
|
||||
String otp = challenge[1].substring(password.length(), challenge[1].length());
|
||||
|
||||
if (checkOtp(context, otp)) {
|
||||
return true;
|
||||
|
@ -55,8 +62,17 @@ public class BasicAuthOTPAuthenticator extends BasicAuthAuthenticator implements
|
|||
}
|
||||
|
||||
private boolean checkOtp(AuthenticationFlowContext context, String otp) {
|
||||
return context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(),
|
||||
boolean valid = context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(),
|
||||
UserCredentialModel.otp(context.getRealm().getOTPPolicy().getType(), otp));
|
||||
|
||||
if (!valid) {
|
||||
context.getEvent().user(context.getUser()).error(Errors.INVALID_USER_CREDENTIALS);
|
||||
Response challengeResponse = challenge(context, Messages.INVALID_TOTP);
|
||||
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challengeResponse);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -66,45 +66,12 @@ public class CliUsernamePasswordAuthenticator extends AbstractUsernameFormAuthen
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Response invalidUser(AuthenticationFlowContext context) {
|
||||
protected Response challenge(AuthenticationFlowContext context, String error) {
|
||||
String header = getHeader(context);
|
||||
Response response = Response.status(401)
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.header(HttpHeaders.WWW_AUTHENTICATE, header)
|
||||
.entity("\n" + context.form().getMessage(Messages.INVALID_USER) + "\n")
|
||||
.build();
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response disabledUser(AuthenticationFlowContext context) {
|
||||
String header = getHeader(context);
|
||||
Response response = Response.status(401)
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.header(HttpHeaders.WWW_AUTHENTICATE, header)
|
||||
.entity("\n" + context.form().getMessage(Messages.ACCOUNT_DISABLED) + "\n")
|
||||
.build();
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response temporarilyDisabledUser(AuthenticationFlowContext context) {
|
||||
String header = getHeader(context);
|
||||
Response response = Response.status(401)
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.header(HttpHeaders.WWW_AUTHENTICATE, header)
|
||||
.entity("\n" + context.form().getMessage(Messages.INVALID_USER) + "\n")
|
||||
.build();
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response invalidCredentials(AuthenticationFlowContext context) {
|
||||
String header = getHeader(context);
|
||||
Response response = Response.status(401)
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.header(HttpHeaders.WWW_AUTHENTICATE, header)
|
||||
.entity("\n" + context.form().getMessage(Messages.INVALID_USER) + "\n")
|
||||
.entity("\n" + context.form().getMessage(error) + "\n")
|
||||
.build();
|
||||
return response;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAu
|
|||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
@ -61,27 +60,8 @@ public class ConsoleUsernamePasswordAuthenticator extends AbstractUsernameFormAu
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Response invalidUser(AuthenticationFlowContext context) {
|
||||
Response response = challenge(context).message(Messages.INVALID_USER);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response disabledUser(AuthenticationFlowContext context) {
|
||||
Response response = challenge(context).message(Messages.ACCOUNT_DISABLED);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response temporarilyDisabledUser(AuthenticationFlowContext context) {
|
||||
Response response = challenge(context).message(Messages.INVALID_USER);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Response invalidCredentials(AuthenticationFlowContext context) {
|
||||
Response response = challenge(context).message(Messages.INVALID_USER);
|
||||
return response;
|
||||
protected Response challenge(AuthenticationFlowContext context, String error) {
|
||||
return challenge(context).message(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -37,13 +37,11 @@ public class DockerAuthenticator extends HttpBasicAuthenticator {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void userDisabledAction(AuthenticationFlowContext context, RealmModel realm, UserModel user) {
|
||||
protected void userDisabledAction(AuthenticationFlowContext context, RealmModel realm, UserModel user, String eventError) {
|
||||
context.getEvent().user(user);
|
||||
context.getEvent().error(Errors.USER_DISABLED);
|
||||
|
||||
context.getEvent().error(eventError);
|
||||
final DockerError error = new DockerError("UNAUTHORIZED","Invalid username or password.",
|
||||
Collections.singletonList(new DockerAccess(context.getAuthenticationSession().getClientNote(DockerAuthV2Protocol.SCOPE_PARAM))));
|
||||
|
||||
context.failure(AuthenticationFlowError.USER_DISABLED, new ResponseBuilderImpl()
|
||||
.status(Response.Status.UNAUTHORIZED)
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
|
||||
|
|
|
@ -4,7 +4,9 @@ import org.jboss.resteasy.spi.HttpRequest;
|
|||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.AuthenticationFlowError;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
|
||||
import org.keycloak.common.util.Base64;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
@ -34,15 +36,21 @@ public class HttpBasicAuthenticator implements Authenticator {
|
|||
final String username = usernameAndPassword[0];
|
||||
final UserModel user = context.getSession().users().getUserByUsername(username, realm);
|
||||
|
||||
// to allow success/failure logging for brute force
|
||||
context.getEvent().detail(Details.USERNAME, username);
|
||||
context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
|
||||
|
||||
if (user != null) {
|
||||
final String password = usernameAndPassword[1];
|
||||
final boolean valid = context.getSession().userCredentialManager().isValid(realm, user, UserCredentialModel.password(password));
|
||||
|
||||
if (valid) {
|
||||
if (user.isEnabled()) {
|
||||
if (isTemporarilyDisabledByBruteForce(context, user)) {
|
||||
userDisabledAction(context, realm, user, Errors.USER_TEMPORARILY_DISABLED);
|
||||
} else if (user.isEnabled()) {
|
||||
userSuccessAction(context, user);
|
||||
} else {
|
||||
userDisabledAction(context, realm, user);
|
||||
userDisabledAction(context, realm, user, Errors.USER_DISABLED);
|
||||
}
|
||||
} else {
|
||||
notValidCredentialsAction(context, realm, user);
|
||||
|
@ -58,8 +66,12 @@ public class HttpBasicAuthenticator implements Authenticator {
|
|||
context.success();
|
||||
}
|
||||
|
||||
protected void userDisabledAction(AuthenticationFlowContext context, RealmModel realm, UserModel user) {
|
||||
userSuccessAction(context, user);
|
||||
protected void userDisabledAction(AuthenticationFlowContext context, RealmModel realm, UserModel user, String eventError) {
|
||||
context.getEvent().user(user);
|
||||
context.getEvent().error(eventError);
|
||||
context.failure(AuthenticationFlowError.INVALID_USER, Response.status(Response.Status.UNAUTHORIZED)
|
||||
.header(HttpHeaders.WWW_AUTHENTICATE, BASIC_PREFIX + "realm=\"" + realm.getName() + "\"")
|
||||
.build());
|
||||
}
|
||||
|
||||
protected void nullUserAction(final AuthenticationFlowContext context, final RealmModel realm, final String user) {
|
||||
|
@ -74,6 +86,11 @@ public class HttpBasicAuthenticator implements Authenticator {
|
|||
.build());
|
||||
}
|
||||
|
||||
private boolean isTemporarilyDisabledByBruteForce(AuthenticationFlowContext context, UserModel user) {
|
||||
return (context.getRealm().isBruteForceProtected())
|
||||
&& (context.getProtector().isTemporarilyDisabled(context.getSession(), context.getRealm(), user));
|
||||
}
|
||||
|
||||
private String[] getUsernameAndPassword(final HttpHeaders httpHeaders) {
|
||||
final List<String> authHeaders = httpHeaders.getRequestHeader(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
|
|
|
@ -376,12 +376,27 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
@Test
|
||||
public void testBrowserInvalidTotp() throws Exception {
|
||||
loginSuccess();
|
||||
loginInvalidPassword();
|
||||
loginWithTotpFailure();
|
||||
loginWithTotpFailure();
|
||||
expectTemporarilyDisabled();
|
||||
expectTemporarilyDisabled("test-user@localhost", null, "invalid");
|
||||
continueLoginWithCorrectTotpExpectFailure();
|
||||
continueLoginWithInvalidTotp();
|
||||
clearUserFailures();
|
||||
loginSuccess();
|
||||
continueLoginWithTotp();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTotpGoingBack() throws Exception {
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
|
||||
continueLoginWithInvalidTotp();
|
||||
loginTotpPage.cancel();
|
||||
loginPage.assertCurrent();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
continueLoginWithInvalidTotp();
|
||||
continueLoginWithCorrectTotpExpectFailure();
|
||||
clearUserFailures();
|
||||
continueLoginWithTotp();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -389,10 +404,14 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
loginSuccess();
|
||||
loginWithMissingTotp();
|
||||
loginWithMissingTotp();
|
||||
expectTemporarilyDisabled();
|
||||
expectTemporarilyDisabled("test-user@localhost", null, "invalid");
|
||||
clearUserFailures();
|
||||
loginSuccess();
|
||||
continueLoginWithMissingTotp();
|
||||
continueLoginWithCorrectTotpExpectFailure();
|
||||
// wait to unlock
|
||||
testingClient.testing().setTimeOffset(Collections.singletonMap("offset", String.valueOf(6)));
|
||||
|
||||
continueLoginWithTotp();
|
||||
|
||||
testingClient.testing().setTimeOffset(Collections.singletonMap("offset", String.valueOf(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -546,7 +565,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
}
|
||||
|
||||
public void loginWithTotpFailure() throws Exception {
|
||||
public void loginWithTotpFailure() {
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
|
||||
|
@ -558,6 +577,51 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
events.clear();
|
||||
}
|
||||
|
||||
public void continueLoginWithTotp() {
|
||||
loginTotpPage.assertCurrent();
|
||||
|
||||
String totpSecret = totp.generateTOTP("totpSecret");
|
||||
loginTotpPage.login(totpSecret);
|
||||
|
||||
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
|
||||
events.expectLogin().assertEvent();
|
||||
appPage.logout();
|
||||
events.clear();
|
||||
}
|
||||
|
||||
public void continueLoginWithCorrectTotpExpectFailure() {
|
||||
loginTotpPage.assertCurrent();
|
||||
|
||||
String totpSecret = totp.generateTOTP("totpSecret");
|
||||
loginTotpPage.login(totpSecret);
|
||||
|
||||
loginTotpPage.assertCurrent();
|
||||
Assert.assertEquals("Invalid authenticator code.", loginTotpPage.getError());
|
||||
|
||||
events.clear();
|
||||
}
|
||||
|
||||
public void continueLoginWithInvalidTotp() {
|
||||
loginTotpPage.assertCurrent();
|
||||
|
||||
loginTotpPage.login("123456");
|
||||
|
||||
loginTotpPage.assertCurrent();
|
||||
Assert.assertEquals("Invalid authenticator code.", loginTotpPage.getError());
|
||||
events.clear();
|
||||
}
|
||||
|
||||
public void continueLoginWithMissingTotp() {
|
||||
loginTotpPage.assertCurrent();
|
||||
|
||||
loginTotpPage.login(null);
|
||||
|
||||
loginTotpPage.assertCurrent();
|
||||
Assert.assertEquals("Invalid authenticator code.", loginTotpPage.getError());
|
||||
events.clear();
|
||||
}
|
||||
|
||||
public void loginWithMissingTotp() throws Exception {
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
|
|
|
@ -29,14 +29,17 @@ import org.keycloak.OAuth2Constants;
|
|||
import org.keycloak.admin.client.resource.ClientsResource;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
|
||||
import org.keycloak.authentication.authenticators.challenge.BasicAuthOTPAuthenticatorFactory;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.AuthenticationFlowBindings;
|
||||
import org.keycloak.models.AuthenticationFlowModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.utils.TimeBasedOTP;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
|
||||
|
@ -44,6 +47,7 @@ import org.keycloak.testsuite.pages.AppPage;
|
|||
import org.keycloak.testsuite.pages.ErrorPage;
|
||||
import org.keycloak.testsuite.pages.LoginPage;
|
||||
import org.keycloak.testsuite.runonserver.RunOnServerDeployment;
|
||||
import org.keycloak.testsuite.util.UserBuilder;
|
||||
import org.keycloak.util.BasicAuthHelper;
|
||||
import org.openqa.selenium.By;
|
||||
|
||||
|
@ -69,6 +73,7 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
public static final String TEST_APP_DIRECT_OVERRIDE = "test-app-direct-override";
|
||||
public static final String TEST_APP_FLOW = "test-app-flow";
|
||||
public static final String TEST_APP_HTTP_CHALLENGE = "http-challenge-client";
|
||||
public static final String TEST_APP_HTTP_CHALLENGE_OTP = "http-challenge-otp-client";
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
@ -82,6 +87,8 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
@Page
|
||||
protected ErrorPage errorPage;
|
||||
|
||||
private TimeBasedOTP totp = new TimeBasedOTP();
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
}
|
||||
|
@ -181,6 +188,22 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
realm.addAuthenticatorExecution(execution);
|
||||
|
||||
AuthenticationFlowModel challengeOTP = new AuthenticationFlowModel();
|
||||
challengeOTP.setAlias("challenge-override-flow");
|
||||
challengeOTP.setDescription("challenge grant based authentication");
|
||||
challengeOTP.setProviderId("basic-flow");
|
||||
challengeOTP.setTopLevel(true);
|
||||
challengeOTP.setBuiltIn(true);
|
||||
|
||||
realm.addAuthenticationFlow(challengeOTP);
|
||||
|
||||
execution = new AuthenticationExecutionModel();
|
||||
execution.setParentFlow(challengeOTP.getId());
|
||||
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
|
||||
execution.setAuthenticator(BasicAuthOTPAuthenticatorFactory.PROVIDER_ID);
|
||||
execution.setPriority(10);
|
||||
realm.addAuthenticatorExecution(execution);
|
||||
|
||||
client = realm.addClient(TEST_APP_DIRECT_OVERRIDE);
|
||||
client.setSecret("password");
|
||||
client.setBaseUrl("http://localhost:8180/auth/realms/master/app/auth");
|
||||
|
@ -203,6 +226,17 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
client.setDirectAccessGrantsEnabled(true);
|
||||
client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.DIRECT_GRANT_BINDING, realm.getFlowByAlias("http challenge").getId());
|
||||
client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.BROWSER_BINDING, realm.getFlowByAlias("http challenge").getId());
|
||||
|
||||
client = realm.addClient(TEST_APP_HTTP_CHALLENGE_OTP);
|
||||
client.setSecret("password");
|
||||
client.setBaseUrl("http://localhost:8180/auth/realms/master/app/auth");
|
||||
client.setManagementUrl("http://localhost:8180/auth/realms/master/app/admin");
|
||||
client.setEnabled(true);
|
||||
client.addRedirectUri("http://localhost:8180/auth/realms/master/app/auth/*");
|
||||
client.setPublicClient(true);
|
||||
client.setDirectAccessGrantsEnabled(true);
|
||||
client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.DIRECT_GRANT_BINDING, realm.getFlowByAlias("challenge-override-flow").getId());
|
||||
client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.BROWSER_BINDING, realm.getFlowByAlias("challenge-override-flow").getId());
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -302,6 +336,7 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
form.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD);
|
||||
form.param("username", "test-user@localhost");
|
||||
form.param("password", "password");
|
||||
|
||||
Response response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, header)
|
||||
.post(Entity.form(form));
|
||||
|
@ -363,6 +398,100 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
events.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDirectGrantHttpChallengeOTP() {
|
||||
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost").get(0);
|
||||
UserRepresentation userUpdated = UserBuilder.edit(user).totpSecret("totpSecret").otpEnabled().build();
|
||||
adminClient.realm("test").users().get(user.getId()).update(userUpdated);
|
||||
|
||||
setupBruteForce();
|
||||
|
||||
Client httpClient = javax.ws.rs.client.ClientBuilder.newClient();
|
||||
String grantUri = oauth.getResourceOwnerPasswordCredentialGrantUrl();
|
||||
WebTarget grantTarget = httpClient.target(grantUri);
|
||||
|
||||
Form form = new Form();
|
||||
form.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD);
|
||||
form.param(OAuth2Constants.CLIENT_ID, TEST_APP_HTTP_CHALLENGE_OTP);
|
||||
|
||||
// correct password + totp
|
||||
String totpCode = totp.generateTOTP("totpSecret");
|
||||
Response response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password" + totpCode))
|
||||
.post(Entity.form(form));
|
||||
assertEquals(200, response.getStatus());
|
||||
response.close();
|
||||
|
||||
// correct password + wrong totp 2x
|
||||
response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password123456"))
|
||||
.post(Entity.form(form));
|
||||
assertEquals(401, response.getStatus());
|
||||
response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password123456"))
|
||||
.post(Entity.form(form));
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
// correct password + totp but user is temporarily locked
|
||||
totpCode = totp.generateTOTP("totpSecret");
|
||||
response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password" + totpCode))
|
||||
.post(Entity.form(form));
|
||||
assertEquals(401, response.getStatus());
|
||||
response.close();
|
||||
|
||||
clearBruteForce();
|
||||
adminClient.realm("test").users().get(user.getId()).removeTotp();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDirectGrantHttpChallengeUserDisabled() {
|
||||
setupBruteForce();
|
||||
|
||||
Client httpClient = javax.ws.rs.client.ClientBuilder.newClient();
|
||||
String grantUri = oauth.getResourceOwnerPasswordCredentialGrantUrl();
|
||||
WebTarget grantTarget = httpClient.target(grantUri);
|
||||
|
||||
Form form = new Form();
|
||||
form.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD);
|
||||
form.param(OAuth2Constants.CLIENT_ID, TEST_APP_HTTP_CHALLENGE);
|
||||
|
||||
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost").get(0);
|
||||
user.setEnabled(false);
|
||||
adminClient.realm("test").users().get(user.getId()).update(user);
|
||||
|
||||
// user disabled
|
||||
Response response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password"))
|
||||
.post(Entity.form(form));
|
||||
assertEquals(401, response.getStatus());
|
||||
assertEquals("Unauthorized", response.getStatusInfo().getReasonPhrase());
|
||||
response.close();
|
||||
|
||||
user.setEnabled(true);
|
||||
adminClient.realm("test").users().get(user.getId()).update(user);
|
||||
|
||||
// lock the user account
|
||||
grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "wrongpassword"))
|
||||
.post(Entity.form(form));
|
||||
grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "wrongpassword"))
|
||||
.post(Entity.form(form));
|
||||
// user is temporarily disabled
|
||||
response = grantTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password"))
|
||||
.post(Entity.form(form));
|
||||
assertEquals(401, response.getStatus());
|
||||
assertEquals("Unauthorized", response.getStatusInfo().getReasonPhrase());
|
||||
response.close();
|
||||
|
||||
clearBruteForce();
|
||||
|
||||
httpClient.close();
|
||||
events.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClientOverrideFlowUsingBrowserHttpChallenge() {
|
||||
Client httpClient = javax.ws.rs.client.ClientBuilder.newClient();
|
||||
|
@ -446,4 +575,20 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
}
|
||||
|
||||
private void setupBruteForce() {
|
||||
RealmRepresentation testRealm = adminClient.realm("test").toRepresentation();
|
||||
testRealm.setBruteForceProtected(true);
|
||||
testRealm.setFailureFactor(2);
|
||||
testRealm.setMaxDeltaTimeSeconds(20);
|
||||
testRealm.setMaxFailureWaitSeconds(100);
|
||||
testRealm.setWaitIncrementSeconds(5);
|
||||
adminClient.realm("test").update(testRealm);
|
||||
}
|
||||
|
||||
private void clearBruteForce() {
|
||||
RealmRepresentation testRealm = adminClient.realm("test").toRepresentation();
|
||||
testRealm.setBruteForceProtected(false);
|
||||
adminClient.realm("test").attackDetection().clearAllBruteForce();
|
||||
adminClient.realm("test").update(testRealm);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue