Reuse of token in TOTP is possible

Fixes #13607
This commit is contained in:
Martin Bartoš 2022-08-24 16:48:26 +02:00 committed by Pedro Igor
parent 040e52cfd7
commit 0fcf5d3936
26 changed files with 434 additions and 123 deletions

View file

@ -24,7 +24,6 @@ import org.jboss.logging.Logger;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
@ -122,6 +121,7 @@ public class RealmRepresentation {
protected Integer otpPolicyDigits;
protected Integer otpPolicyLookAheadWindow;
protected Integer otpPolicyPeriod;
protected Boolean otpPolicyCodeReusable;
protected List<String> otpSupportedApplications;
// WebAuthn 2-factor properties below
@ -1025,6 +1025,14 @@ public class RealmRepresentation {
this.otpSupportedApplications = otpSupportedApplications;
}
public Boolean isOtpPolicyCodeReusable() {
return otpPolicyCodeReusable;
}
public void setOtpPolicyCodeReusable(Boolean isCodeReusable) {
this.otpPolicyCodeReusable = isCodeReusable;
}
// WebAuthn 2-factor properties below
public String getWebAuthnPolicyRpEntityName() {

View file

@ -894,6 +894,7 @@ public class RealmAdapter implements LegacyRealmModel, JpaModel<RealmEntity> {
otpPolicy.setLookAheadWindow(realm.getOtpPolicyLookAheadWindow());
otpPolicy.setType(realm.getOtpPolicyType());
otpPolicy.setPeriod(realm.getOtpPolicyPeriod());
otpPolicy.setCodeReusable(getAttribute(OTPPolicy.REALM_REUSABLE_CODE_ATTRIBUTE, OTPPolicy.DEFAULT_IS_REUSABLE));
}
return otpPolicy;
}
@ -906,6 +907,7 @@ public class RealmAdapter implements LegacyRealmModel, JpaModel<RealmEntity> {
realm.setOtpPolicyLookAheadWindow(policy.getLookAheadWindow());
realm.setOtpPolicyType(policy.getType());
realm.setOtpPolicyPeriod(policy.getPeriod());
setAttribute(OTPPolicy.REALM_REUSABLE_CODE_ATTRIBUTE, policy.isCodeReusable());
em.flush();
}

View file

@ -1374,8 +1374,8 @@ public class LegacyExportImportManager implements ExportImportManager {
if (rep.getOtpPolicyAlgorithm() != null) policy.setAlgorithm(rep.getOtpPolicyAlgorithm());
if (rep.getOtpPolicyDigits() != null) policy.setDigits(rep.getOtpPolicyDigits());
if (rep.getOtpPolicyPeriod() != null) policy.setPeriod(rep.getOtpPolicyPeriod());
if (rep.isOtpPolicyCodeReusable() != null) policy.setCodeReusable(rep.isOtpPolicyCodeReusable());
return policy;
}

View file

@ -20,6 +20,8 @@ public class HotRodOTPPolicyEntity extends AbstractHotRodEntity {
public String otpPolicyAlgorithm;
@ProtoField(number = 6)
public String otpPolicyType;
@ProtoField(number = 7)
public Boolean otpPolicyCodeReusable;
@Override
public boolean equals(Object o) {
return HotRodOTPPolicyEntityDelegate.entityEquals(this, o);

View file

@ -1280,8 +1280,8 @@ public class MapExportImportManager implements ExportImportManager {
if (rep.getOtpPolicyAlgorithm() != null) policy.setAlgorithm(rep.getOtpPolicyAlgorithm());
if (rep.getOtpPolicyDigits() != null) policy.setDigits(rep.getOtpPolicyDigits());
if (rep.getOtpPolicyPeriod() != null) policy.setPeriod(rep.getOtpPolicyPeriod());
if (rep.isOtpPolicyCodeReusable() != null) policy.setCodeReusable(rep.isOtpPolicyCodeReusable());
return policy;
}

View file

@ -34,22 +34,31 @@ public interface MapOTPPolicyEntity extends UpdatableEntity {
entity.setOtpPolicyLookAheadWindow(model.getLookAheadWindow());
entity.setOtpPolicyType(model.getType());
entity.setOtpPolicyPeriod(model.getPeriod());
entity.setOtpPolicyCodeReusable(model.isCodeReusable());
return entity;
}
static OTPPolicy toModel(MapOTPPolicyEntity entity) {
if (entity == null) return null;
OTPPolicy model = new OTPPolicy();
Integer otpPolicyDigits = entity.getOtpPolicyDigits();
model.setDigits(otpPolicyDigits == null ? 0 : otpPolicyDigits);
model.setAlgorithm(entity.getOtpPolicyAlgorithm());
Integer otpPolicyInitialCounter = entity.getOtpPolicyInitialCounter();
model.setInitialCounter(otpPolicyInitialCounter == null ? 0 : otpPolicyInitialCounter);
Integer otpPolicyLookAheadWindow = entity.getOtpPolicyLookAheadWindow();
model.setLookAheadWindow(otpPolicyLookAheadWindow == null ? 0 : otpPolicyLookAheadWindow);
model.setType(entity.getOtpPolicyType());
Integer otpPolicyPeriod = entity.getOtpPolicyPeriod();
model.setPeriod(otpPolicyPeriod == null ? 0 : otpPolicyPeriod);
Boolean isOtpPolicyReusable = entity.isOtpPolicyCodeReusable();
model.setCodeReusable(isOtpPolicyReusable == null ? OTPPolicy.DEFAULT_IS_REUSABLE : isOtpPolicyReusable);
return model;
}
@ -70,4 +79,7 @@ public interface MapOTPPolicyEntity extends UpdatableEntity {
String getOtpPolicyAlgorithm();
void setOtpPolicyAlgorithm(String otpPolicyAlgorithm);
Boolean isOtpPolicyCodeReusable();
void setOtpPolicyCodeReusable(Boolean isOtpPolicyCodeReusable);
}

View file

@ -418,6 +418,7 @@ public class ModelToRepresentation {
rep.setOtpPolicyType(otpPolicy.getType());
rep.setOtpPolicyLookAheadWindow(otpPolicy.getLookAheadWindow());
rep.setOtpSupportedApplications(otpPolicy.getSupportedApplications());
rep.setOtpPolicyCodeReusable(otpPolicy.isCodeReusable());
WebAuthnPolicy webAuthnPolicy = realm.getWebAuthnPolicy();
rep.setWebAuthnPolicyRpEntityName(webAuthnPolicy.getRpEntityName());

View file

@ -44,6 +44,7 @@ public class OTPPolicy implements Serializable {
protected int digits;
protected int lookAheadWindow;
protected int period;
protected boolean isCodeReusable;
private static final Map<String, String> algToKeyUriAlg = new HashMap<>();
@ -59,15 +60,24 @@ public class OTPPolicy implements Serializable {
}
public OTPPolicy(String type, String algorithm, int initialCounter, int digits, int lookAheadWindow, int period) {
this(type, algorithm, initialCounter, digits, lookAheadWindow, period, DEFAULT_IS_REUSABLE);
}
public OTPPolicy(String type, String algorithm, int initialCounter, int digits, int lookAheadWindow, int period, boolean isCodeReusable) {
this.type = type;
this.algorithm = algorithm;
this.initialCounter = initialCounter;
this.digits = digits;
this.lookAheadWindow = lookAheadWindow;
this.period = period;
this.isCodeReusable = isCodeReusable;
}
public static OTPPolicy DEFAULT_POLICY = new OTPPolicy(OTPCredentialModel.TOTP, HmacOTP.HMAC_SHA1, 0, 6, 1, 30);
public static final boolean DEFAULT_IS_REUSABLE = false;
// Realm attributes
public static final String REALM_REUSABLE_CODE_ATTRIBUTE = "realmReusableOtpCode";
public String getAlgorithmKey() {
return algToKeyUriAlg.containsKey(algorithm) ? algToKeyUriAlg.get(algorithm) : algorithm;
@ -121,8 +131,17 @@ public class OTPPolicy implements Serializable {
this.period = period;
}
public boolean isCodeReusable() {
return isCodeReusable;
}
public void setCodeReusable(boolean isReusable) {
isCodeReusable = isReusable;
}
/**
* Constructs the <code>otpauth://</code> URI based on the <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Key-Uri-Format</a>.
*
* @param realm
* @param user
* @param secret

View file

@ -19,14 +19,15 @@ package org.keycloak.credential;
import org.jboss.logging.Logger;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Time;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.dto.OTPCredentialData;
import org.keycloak.models.credential.dto.OTPSecretData;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.dto.OTPCredentialData;
import org.keycloak.models.credential.dto.OTPSecretData;
import org.keycloak.models.utils.HmacOTP;
import org.keycloak.models.utils.TimeBasedOTP;
@ -99,6 +100,7 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
OTPSecretData secretData = otpCredentialModel.getOTPSecretData();
OTPCredentialData credentialData = otpCredentialModel.getOTPCredentialData();
OTPPolicy policy = realm.getOTPPolicy();
if (OTPCredentialModel.HOTP.equals(credentialData.getSubType())) {
HmacOTP validator = new HmacOTP(credentialData.getDigits(), credentialData.getAlgorithm(), policy.getLookAheadWindow());
int counter = validator.validateHOTP(challengeResponse, secretData.getValue(), credentialData.getCounter());
@ -110,7 +112,17 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
return true;
} else if (OTPCredentialModel.TOTP.equals(credentialData.getSubType())) {
TimeBasedOTP validator = new TimeBasedOTP(credentialData.getAlgorithm(), credentialData.getDigits(), credentialData.getPeriod(), policy.getLookAheadWindow());
return validator.validateTOTP(challengeResponse, secretData.getValue().getBytes(StandardCharsets.UTF_8));
final boolean isValid = validator.validateTOTP(challengeResponse, secretData.getValue().getBytes(StandardCharsets.UTF_8));
if (isValid) {
if (policy.isCodeReusable()) return true;
SingleUseObjectProvider singleUseStore = session.getProvider(SingleUseObjectProvider.class);
final long validLifespan = (long) credentialData.getPeriod() * (2L * policy.getLookAheadWindow() + 1);
final String searchKey = credential.getId() + "." + challengeResponse;
return singleUseStore.putIfAbsent(searchKey, validLifespan);
}
}
return false;
}

View file

@ -2,6 +2,7 @@ package org.keycloak.testsuite.updaters;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.RealmRepresentation;
import java.util.HashMap;
import java.util.List;
@ -117,4 +118,40 @@ public class RealmAttributeUpdater extends ServerResourceUpdater<RealmAttributeU
rep.setInternationalizationEnabled(internationalizationEnabled);
return this;
}
// OTP Policy
public RealmAttributeUpdater setOtpPolicyAlgorithm(String otpPolicyAlgorithm) {
rep.setOtpPolicyAlgorithm(otpPolicyAlgorithm);
return this;
}
public RealmAttributeUpdater setOtpPolicyDigits(Integer otpPolicyDigits) {
rep.setOtpPolicyDigits(otpPolicyDigits);
return this;
}
public RealmAttributeUpdater setOtpPolicyInitialCounter(Integer otpPolicyInitialCounter) {
rep.setOtpPolicyInitialCounter(otpPolicyInitialCounter);
return this;
}
public RealmAttributeUpdater setOtpPolicyPeriod(Integer otpPolicyPeriod) {
rep.setOtpPolicyPeriod(otpPolicyPeriod);
return this;
}
public RealmAttributeUpdater setOtpPolicyType(String otpPolicyType) {
rep.setOtpPolicyType(otpPolicyType);
return this;
}
public RealmAttributeUpdater setOtpPolicyLookAheadWindow(Integer otpPolicyLookAheadWindow) {
rep.setOtpPolicyLookAheadWindow(otpPolicyLookAheadWindow);
return this;
}
public RealmAttributeUpdater setOtpPolicyCodeReusable(Boolean isCodeReusable) {
rep.setOtpPolicyCodeReusable(isCodeReusable);
return this;
}
}

View file

@ -40,6 +40,7 @@ import org.keycloak.common.util.Time;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.cache.CacheRealmProvider;
import org.keycloak.models.cache.UserCache;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.provider.Provider;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@ -69,7 +70,6 @@ import org.openqa.selenium.WebDriver;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
@ -85,20 +85,25 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Scanner;
import java.util.concurrent.*;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.keycloak.testsuite.admin.Users.setPasswordFor;
import static org.keycloak.testsuite.auth.page.AuthRealm.MASTER;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_HOST;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SCHEME;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED;
import static org.keycloak.testsuite.auth.page.AuthRealm.MASTER;
import static org.keycloak.testsuite.util.URLUtils.navigateToUri;
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
import static org.keycloak.testsuite.util.URLUtils.navigateToUri;
/**
*
@ -654,6 +659,13 @@ public abstract class AbstractKeycloakTest {
log.debugv("Reset time offset, response {0}", response);
}
public void setOtpTimeOffset(int offsetSeconds, TimeBasedOTP otp) {
setTimeOffset(offsetSeconds);
final Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, offsetSeconds);
otp.setCalendar(calendar);
}
public int getCurrentTime() {
return Time.currentTime();
}

View file

@ -31,19 +31,32 @@ import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.admin.Users;
import org.keycloak.testsuite.auth.page.login.OneTimeCode;
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.PageUtils;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.*;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.DEFAULT_OTP_OUTCOME;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE_OTP_FOR_HTTP_HEADER;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.FORCE_OTP_ROLE;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OTP_CONTROL_USER_ATTRIBUTE;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP_OTP_FOR_HTTP_HEADER;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.SKIP_OTP_ROLE;
import static org.keycloak.models.UserModel.RequiredAction.CONFIGURE_TOTP;
import static org.keycloak.representations.idm.CredentialRepresentation.PASSWORD;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT;
@ -65,6 +78,9 @@ public class CustomAuthFlowOTPTest extends AbstractCustomAccountManagementTest {
@Page
private LoginConfigTotpPage loginConfigTotpPage;
@Page
private LoginTotpPage loginTotpPage;
@Override
public void setDefaultPageUriParameters() {
super.setDefaultPageUriParameters();
@ -119,6 +135,58 @@ public class CustomAuthFlowOTPTest extends AbstractCustomAccountManagementTest {
assertCurrentUrlStartsWith(testLoginOneTimeCodePage);
}
@Test
public void reuseExistingOTP() {
reuseExistingOtp(true);
}
@Test
public void notReuseExistingOTP() {
reuseExistingOtp(false);
}
private void reuseExistingOtp(boolean allowReusingExistingOtp) {
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealmResource())
.setBrowserFlow("browser")
.setOtpPolicyCodeReusable(allowReusingExistingOtp)
.update()) {
//update realm browser flow
RealmRepresentation realm = testRealmResource().toRepresentation();
realm.setBrowserFlow("browser");
testRealmResource().update(realm);
updateRequirement("browser", Requirement.REQUIRED, (authExec) -> authExec.getDisplayName().equals("Browser - Conditional OTP"));
testRealmAccountManagementPage.navigateTo();
testRealmLoginPage.form().login(testUser);
assertTrue(loginConfigTotpPage.isCurrent());
//configure OTP for test user
testRealmAccountManagementPage.navigateTo();
testRealmLoginPage.form().login(testUser);
final String totpSecret = testRealmLoginPage.form().totpForm().getTotpSecret();
assertThat(totpSecret, notNullValue());
final String generatedOtp = totp.generateTOTP(totpSecret);
assertThat(generatedOtp, notNullValue());
testRealmLoginPage.form().totpForm().setTotp(generatedOtp);
testRealmLoginPage.form().totpForm().submit();
testRealmAccountManagementPage.signOut();
testRealmAccountManagementPage.navigateTo();
testRealmLoginPage.form().login(testUser);
loginTotpPage.assertCurrent();
loginTotpPage.login(generatedOtp);
assertThat(loginTotpPage.isCurrent(), is(!allowReusingExistingOtp));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
@AuthServerContainerExclude(AuthServer.REMOTE)
public void conditionalOTPNoDefault() {

View file

@ -363,6 +363,8 @@ public class AppInitiatedActionTotpSetupTest extends AbstractAppInitiatedActionT
events.expectLogout(authSessionId).assertEvent();
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
loginPage.open();
loginPage.login("test-user@localhost", "password");
@ -412,6 +414,8 @@ public class AppInitiatedActionTotpSetupTest extends AbstractAppInitiatedActionT
assertTrue(loginPage.isCurrent());
Assert.assertFalse(totpPage.isCurrent());
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
// Login with one-time password
loginTotpPage.login(totp.generateTOTP(totpCode));
@ -472,6 +476,8 @@ public class AppInitiatedActionTotpSetupTest extends AbstractAppInitiatedActionT
events.expectLogout(loginEvent.getSessionId()).assertEvent();
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, timeBased);
loginPage.open();
loginPage.login("test-user@localhost", "password");
String src = driver.getPageSource();

View file

@ -47,14 +47,15 @@ import org.keycloak.testsuite.pages.LoginConfigTotpPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import org.openqa.selenium.By;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@ -281,6 +282,8 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
String customOtpLabel = "my-custom-otp-label";
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
// Set OTP label to a custom value
totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret()), customOtpLabel);
@ -325,7 +328,18 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
}
@Test
public void setupTotpExisting() {
public void setupTotpExistingReusableCodeEnabled() throws IOException {
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()).setOtpPolicyCodeReusable(true).update()) {
setupTotpExisting(true);
}
}
@Test
public void setupTotpExistingReusableCodeDisabled() {
setupTotpExisting(false); // Default value
}
public void setupTotpExisting(boolean reusableCodesEnabled) {
loginPage.open();
loginPage.login("test-user@localhost", "password");
@ -348,15 +362,22 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
events.expectLogout(authSessionId).assertEvent();
if (!reusableCodesEnabled) {
setTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS);
}
loginPage.open();
loginPage.login("test-user@localhost", "password");
String src = driver.getPageSource();
loginTotpPage.login(totp.generateTOTP(totpSecret));
if (!reusableCodesEnabled) {
loginTotpPage.assertCurrent();
} else {
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().assertEvent();
}
}
//KEYCLOAK-15511
@Test
@ -409,6 +430,8 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
oauth.idTokenHint(tokenResponse.getIdToken()).openLogout();
events.expectLogout(loginEvent.getSessionId()).user(userId).assertEvent();
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
// Try to login after logout
loginPage.open();
loginPage.login("setupTotp2", "password2");
@ -437,6 +460,8 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
accountTotpPage.logout();
events.expectLogout(loginEvent.getSessionId()).user(userId).detail(Details.REDIRECT_URI, oauth.AUTH_SERVER_ROOT + "/realms/test/account/totp").assertEvent();
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
// Try to login
loginPage.open();
loginPage.login("setupTotp2", "password2");
@ -489,6 +514,8 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
events.expectLogout(loginEvent.getSessionId()).assertEvent();
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, timeBased);
loginPage.open();
loginPage.login("test-user@localhost", "password");
String src = driver.getPageSource();

View file

@ -17,7 +17,9 @@
package org.keycloak.testsuite.admin.realm;
import com.google.common.collect.Sets;
import org.apache.commons.io.IOUtils;
import org.hamcrest.CoreMatchers;
import org.hamcrest.Matchers;
import org.junit.Assume;
import org.junit.Rule;
@ -35,6 +37,7 @@ import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.ParConfig;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -59,6 +62,7 @@ import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.A
import org.keycloak.testsuite.auth.page.AuthRealm;
import org.keycloak.testsuite.client.KeycloakTestingClient;
import org.keycloak.testsuite.events.TestEventsListenerProviderFactory;
import org.keycloak.testsuite.model.StoreProvider;
import org.keycloak.testsuite.runonserver.RunHelpers;
import org.keycloak.testsuite.updaters.Creator;
import org.keycloak.testsuite.util.AdminEventPaths;
@ -74,16 +78,24 @@ import javax.ws.rs.BadRequestException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
@ -192,11 +204,23 @@ public class RealmTest extends AbstractAdminTest {
CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT).stream().forEach(i -> rep2.getAttributes().remove(i));
}
Map<String, String> attributes = rep2.getAttributes();
assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()),
attributes.size() == 3 && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN)
&& attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL)
&& attributes.containsKey(ParConfig.PAR_REQUEST_URI_LIFESPAN));
Set<String> attributesKeys = rep2.getAttributes().keySet();
int expectedAttributesCount = 3;
final Set<String> expectedAttributes = Sets.newHashSet(
OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN,
OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL,
ParConfig.PAR_REQUEST_URI_LIFESPAN
);
// This attribute is represented in Legacy store as attribute and for Map store as a field
if (!StoreProvider.getCurrentProvider().isMapStore()) {
expectedAttributes.add(OTPPolicy.REALM_REUSABLE_CODE_ATTRIBUTE);
expectedAttributesCount++;
}
assertThat(attributesKeys.size(), CoreMatchers.is(expectedAttributesCount));
assertThat(attributesKeys, CoreMatchers.is(expectedAttributes));
} finally {
adminClient.realm("attributes").remove();
}

View file

@ -8,6 +8,8 @@ import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
@ -473,6 +475,8 @@ public abstract class AbstractAdvancedBrokerTest extends AbstractBrokerTest {
assertNumFederatedIdentities(realm.users().search(bc.getUserLogin()).get(0).getId(), 1);
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
logInWithBroker(bc);
loginTotpPage.assertCurrent();
@ -512,6 +516,8 @@ public abstract class AbstractAdvancedBrokerTest extends AbstractBrokerTest {
assertNumFederatedIdentities(realm.users().search(bc.getUserLogin()).get(0).getId(), 1);
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
logInWithBroker(bc);
loginTotpPage.assertCurrent();
@ -531,6 +537,8 @@ public abstract class AbstractAdvancedBrokerTest extends AbstractBrokerTest {
String userId = ApiUtil.findUserByUsername(realm, bc.getUserLogin()).getId();
realm.attackDetection().clearBruteForceForUser(userId);
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
loginTotpPage.login(totp.generateTOTP(totpSecret));
waitForAccountManagementTitle();
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());

View file

@ -5,6 +5,7 @@ import com.google.common.collect.Lists;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientsResource;
@ -21,20 +22,18 @@ import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.Urls;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.WaitUtils;
@ -51,6 +50,7 @@ import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertThat;
import static org.keycloak.models.utils.TimeBasedOTP.DEFAULT_INTERVAL_SECONDS;
import static org.keycloak.testsuite.admin.ApiUtil.removeUserByUsername;
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.configurePostBrokerLoginWithOTP;
import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_PROV_NAME;
@ -69,6 +69,11 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
return KcOidcBrokerConfiguration.INSTANCE;
}
@Before
public void setUpTotp() {
totp = new TimeBasedOTP();
}
@Override
protected Iterable<IdentityProviderMapperRepresentation> createIdentityProviderMappers(IdentityProviderMapperSyncMode syncMode) {
IdentityProviderMapperRepresentation attrMapper1 = new IdentityProviderMapperRepresentation();
@ -258,6 +263,8 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
totpPage.configure(totp.generateTOTP(totpSecret));
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
setOtpTimeOffset(DEFAULT_INTERVAL_SECONDS, totp);
logInWithBroker(bc);
waitForPage(driver, "account already exists", false);
@ -320,14 +327,19 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
*/
@Test
public void testReauthenticationBothBrokersWithOTPRequired() throws Exception {
final RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName());
final RealmResource providerRealm = adminClient.realm(bc.providerRealmName());
try (RealmAttributeUpdater rauConsumer = new RealmAttributeUpdater(consumerRealm).setOtpPolicyCodeReusable(true).update();
RealmAttributeUpdater rauProvider = new RealmAttributeUpdater(providerRealm).setOtpPolicyCodeReusable(true).update()) {
KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE;
ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0);
IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider();
RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName());
try {
updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin);
adminClient.realm(bc.providerRealmName()).clients().create(samlClient);
providerRealm.clients().create(samlClient);
consumerRealm.identityProviders().create(samlBroker);
driver.navigate().to(getAccountUrl(getConsumerRoot(), bc.consumerRealmName()));
@ -364,6 +376,7 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
removeUserByUsername(consumerRealm, "consumer");
}
}
}
@Test
public void testInvalidIssuedFor() {

View file

@ -6,6 +6,7 @@ import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil;
@ -81,6 +82,8 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
String consumerRealmUserId = createUser("consumer");
String totpSecret = addTOTPToUser("consumer");
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
loginWithBrokerAndConfirmLinkAccount();
// Login with password
@ -164,6 +167,8 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
String consumerRealmUserId = createUser("consumer");
String totpSecret = addTOTPToUser("consumer");
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
loginWithBrokerAndConfirmLinkAccount();
// Assert that user can see credentials combobox. Password and OTP are available credentials. Password should be selected.

View file

@ -53,6 +53,7 @@ import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.UserBuilder;
import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED;
@ -146,7 +147,8 @@ public class UserStorageOTPTest extends AbstractTestRealmKeycloakTest {
@Test
public void testUpdateOTP() {
public void testUpdateOTP() throws IOException {
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()).setOtpPolicyCodeReusable(true).update()) {
// Add requiredAction to the user for update OTP
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user");
UserRepresentation userRep = user.toRepresentation();
@ -205,6 +207,7 @@ public class UserStorageOTPTest extends AbstractTestRealmKeycloakTest {
appPage.logout(idTokenHint);
events.expectLogout(loginEvent.getSessionId()).user(userRep.getId()).assertEvent();
}
}
@Test
public void testNormalUser() {

View file

@ -53,6 +53,7 @@ import org.keycloak.testsuite.util.UserBuilder;
import javax.mail.internet.MimeMessage;
import java.net.MalformedURLException;
import java.util.Calendar;
import java.util.Collections;
import java.util.Map;
@ -112,6 +113,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
testRealm.setMaxDeltaTimeSeconds(20);
testRealm.setMaxFailureWaitSeconds(100);
testRealm.setWaitIncrementSeconds(5);
testRealm.setOtpPolicyCodeReusable(true);
//testRealm.setQuickLoginCheckMilliSeconds(0L);
userId = user.getId();
@ -130,6 +132,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
realm.setMaxDeltaTimeSeconds(20);
realm.setMaxFailureWaitSeconds(100);
realm.setWaitIncrementSeconds(5);
realm.setOtpPolicyCodeReusable(true);
adminClient.realm("test").update(realm);
} catch (Exception e) {
throw new RuntimeException(e);

View file

@ -27,6 +27,7 @@ import javax.ws.rs.BadRequestException;
import javax.ws.rs.core.UriBuilder;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
@ -62,11 +63,13 @@ import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.PushTheButtonPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.RealmRepUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.util.JsonSerialization;
import static org.hamcrest.CoreMatchers.is;
@ -100,6 +103,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
try {
testRealm.setOtpPolicyCodeReusable(true);
findTestApp(testRealm).setAttributes(Collections.singletonMap(Constants.ACR_LOA_MAP, getAcrToLoaMappingForClient()));
UserRepresentation user = RealmRepUtil.findUser(testRealm, "test-user@localhost");
UserBuilder.edit(user)
@ -121,6 +125,19 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
@Before
public void setupFlow() {
configureStepUpFlow(testingClient);
canBeOtpCodesReusable(true);
}
@After
public void tearDown() {
canBeOtpCodesReusable(false);
}
// Fixing this test with not reusable OTP codes would bring additional unwanted workarounds; not scope of this test
private void canBeOtpCodesReusable(boolean state) {
new RealmAttributeUpdater(testRealm())
.setOtpPolicyCodeReusable(state)
.update();
}
public static void configureStepUpFlow(KeycloakTestingClient testingClient) {

View file

@ -24,6 +24,7 @@ import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.events.Details;
import org.keycloak.models.Constants;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
@ -33,11 +34,13 @@ import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmRepUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.WaitUtils;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
@ -132,6 +135,8 @@ public class LoginTotpTest extends AbstractTestRealmKeycloakTest {
Assert.assertTrue(loginTotpPage.isCurrent());
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
loginTotpPage.login(totp.generateTOTP("totpSecret"));
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
@ -199,7 +204,7 @@ public class LoginTotpTest extends AbstractTestRealmKeycloakTest {
@Test
public void loginWithTotp_getToken_checkCompatibilityCLI() throws IOException {
Client httpClient = AdminClientUtil.createResteasyClient();
try {
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()).setOtpPolicyCodeReusable(true).update()) {
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)

View file

@ -21,6 +21,8 @@
*/
package org.keycloak.testsuite.console.page.authentication.otppolicy;
import org.keycloak.models.OTPPolicy;
import org.keycloak.testsuite.console.page.fragment.OnOffSwitch;
import org.keycloak.testsuite.page.Form;
import org.keycloak.testsuite.util.UIUtils;
import org.openqa.selenium.WebElement;
@ -54,7 +56,14 @@ public class OTPPolicyForm extends Form {
@FindBy(id = "counter")
private WebElement counter;
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='reusableCode']]")
private OnOffSwitch reusableCode;
public void setValues(OTPType otpType, OTPHashAlg otpHashAlg, Digits digits, String lookAheadOrAround, String periodOrCounter) {
setValues(otpType, otpHashAlg, digits, lookAheadOrAround, periodOrCounter, OTPPolicy.DEFAULT_IS_REUSABLE);
}
public void setValues(OTPType otpType, OTPHashAlg otpHashAlg, Digits digits, String lookAheadOrAround, String periodOrCounter, boolean isReusableCode) {
this.otpType.selectByValue(otpType.getName());
this.otpHashAlg.selectByValue(otpHashAlg.getName());
this.digits.selectByVisibleText("" + digits.getName());
@ -63,6 +72,7 @@ public class OTPPolicyForm extends Form {
case TIME_BASED:
UIUtils.setTextInputValue(this.lookAround, lookAheadOrAround);
UIUtils.setTextInputValue(period, periodOrCounter);
reusableCode.setOn(isReusableCode);
break;
case COUNTER_BASED:
UIUtils.setTextInputValue(this.lookAhead, lookAheadOrAround);

View file

@ -32,7 +32,9 @@ import org.keycloak.testsuite.console.page.authentication.otppolicy.OTPPolicyFor
import org.keycloak.testsuite.console.page.authentication.otppolicy.OTPPolicyForm.OTPType;
import org.keycloak.testsuite.util.WaitUtils;
import static org.junit.Assert.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
/**
*
@ -60,13 +62,16 @@ public class OTPPolicyTest extends AbstractConsoleTest {
assertEquals(Integer.valueOf(8), realm.getOtpPolicyDigits());
assertEquals(Integer.valueOf(10), realm.getOtpPolicyLookAheadWindow());
assertEquals(Integer.valueOf(50), realm.getOtpPolicyInitialCounter());
assertThat(realm.isOtpPolicyCodeReusable(), is(org.keycloak.models.OTPPolicy.DEFAULT_IS_REUSABLE));
otpPolicyPage.form().setValues(OTPType.TIME_BASED, OTPHashAlg.SHA512, Digits.EIGHT, "10", "40");
otpPolicyPage.form().setValues(OTPType.TIME_BASED, OTPHashAlg.SHA512, Digits.EIGHT, "10", "40", false);
assertAlertSuccess();
realm = testRealmResource().toRepresentation();
assertEquals("totp", realm.getOtpPolicyType());
assertEquals(Integer.valueOf(40), realm.getOtpPolicyPeriod());
assertThat(realm.isOtpPolicyCodeReusable(), is(false));
}
@Test

View file

@ -1371,6 +1371,8 @@ otp-token-period=OTP Token Period
otp-token-period.tooltip=How many seconds should an OTP token be valid? Defaults to 30 seconds.
otp-supported-applications=Supported Applications
otp-supported-applications.tooltip=Applications that are known to work with the current OTP policy
otp-reusable-code=Reusable token
otp-reusable-code.tooltip=Possibility to use the same OTP code again after successful authentication.
loa-level=Level of Authentication
loa-level.tooltip=Sets the Level of Authentication to the specified value.
loa-max-age=Max Age

View file

@ -81,6 +81,16 @@
<kc-tooltip>{{:: 'otp-supported-applications.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-if="realm.otpPolicyType == 'totp'">
<label for="reusableCode" class="col-md-2 control-label">{{:: 'otp-reusable-code' | translate}}</label>
<div class="col-md-2">
<div>
<input id="reusableCode" ng-model="realm.otpPolicyCodeReusable" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<kc-tooltip>{{:: 'otp-reusable-code.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="access.manageRealm">
<div class="col-md-10 col-md-offset-2">