From b303acaabaee8e3a1bcb15c1c294327aafba348a Mon Sep 17 00:00:00 2001 From: stianst Date: Fri, 15 Dec 2017 14:25:21 +0100 Subject: [PATCH] KEYCLOAK-2120 Added manual setup page for OTP --- .../idm/RealmRepresentation.java | 9 ++ .../forms/account/AccountProvider.java | 2 + .../models/utils/ModelToRepresentation.java | 1 + .../java/org/keycloak/models/OTPPolicy.java | 60 +++++++++ .../requiredactions/UpdateTotp.java | 7 ++ .../freemarker/FreeMarkerAccountProvider.java | 18 ++- .../account/freemarker/model/TotpBean.java | 21 +++- .../FreeMarkerLoginFormsProvider.java | 2 +- .../login/freemarker/model/TotpBean.java | 22 +++- .../resources/account/AccountFormService.java | 4 + .../testsuite/pages/AccountTotpPage.java | 14 +++ .../testsuite/pages/LoginConfigTotpPage.java | 14 +++ .../account/AccountFormServiceTest.java | 35 ++++++ .../actions/RequiredActionTotpSetupTest.java | 119 ++++++++++++++++-- .../account/messages/messages_en.properties | 17 ++- .../resources/theme/base/account/totp.ftl | 36 +++++- .../messages/admin-messages_en.properties | 2 + .../admin/resources/js/controllers/realm.js | 36 ++---- .../admin/resources/partials/otp-policy.html | 8 ++ .../theme/base/login/login-config-totp.ftl | 47 +++++-- .../login/messages/messages_en.properties | 16 ++- .../account/resources/css/account.css | 8 ++ .../keycloak/login/resources/css/login.css | 7 ++ 23 files changed, 444 insertions(+), 61 deletions(-) diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java index 4a7396c5d4..cd52d7ce0d 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -99,6 +99,7 @@ public class RealmRepresentation { protected Integer otpPolicyDigits; protected Integer otpPolicyLookAheadWindow; protected Integer otpPolicyPeriod; + protected List otpSupportedApplications; protected List users; protected List federatedUsers; @@ -854,6 +855,14 @@ public class RealmRepresentation { this.otpPolicyPeriod = otpPolicyPeriod; } + public List getOtpSupportedApplications() { + return otpSupportedApplications; + } + + public void setOtpSupportedApplications(List otpSupportedApplications) { + this.otpSupportedApplications = otpSupportedApplications; + } + public String getBrowserFlow() { return browserFlow; } diff --git a/server-spi-private/src/main/java/org/keycloak/forms/account/AccountProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/account/AccountProvider.java index debc52f944..a61c0a941b 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/account/AccountProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/account/AccountProvider.java @@ -66,4 +66,6 @@ public interface AccountProvider extends Provider { AccountProvider setStateChecker(String stateChecker); AccountProvider setFeatures(boolean social, boolean events, boolean passwordUpdateSupported); + + AccountProvider setAttribute(String key, String value); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 492773c9a9..aa6b42c42b 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -282,6 +282,7 @@ public class ModelToRepresentation { rep.setOtpPolicyInitialCounter(otpPolicy.getInitialCounter()); rep.setOtpPolicyType(otpPolicy.getType()); rep.setOtpPolicyLookAheadWindow(otpPolicy.getLookAheadWindow()); + rep.setOtpSupportedApplications(otpPolicy.getSupportedApplications()); if (realm.getBrowserFlow() != null) rep.setBrowserFlow(realm.getBrowserFlow().getAlias()); if (realm.getRegistrationFlow() != null) rep.setRegistrationFlow(realm.getRegistrationFlow().getAlias()); if (realm.getDirectGrantFlow() != null) rep.setDirectGrantFlow(realm.getDirectGrantFlow().getAlias()); diff --git a/server-spi/src/main/java/org/keycloak/models/OTPPolicy.java b/server-spi/src/main/java/org/keycloak/models/OTPPolicy.java index 0acf02d335..83a27fc36a 100755 --- a/server-spi/src/main/java/org/keycloak/models/OTPPolicy.java +++ b/server-spi/src/main/java/org/keycloak/models/OTPPolicy.java @@ -25,6 +25,8 @@ import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; import java.util.Map; /** @@ -44,6 +46,8 @@ public class OTPPolicy implements Serializable { private static final Map algToKeyUriAlg = new HashMap<>(); + private static final OtpApp[] allApplications = new OtpApp[] { new FreeOTP(), new GoogleAuthenticator() }; + static { algToKeyUriAlg.put(HmacOTP.HMAC_SHA1, "SHA1"); algToKeyUriAlg.put(HmacOTP.HMAC_SHA256, "SHA256"); @@ -151,4 +155,60 @@ public class OTPPolicy implements Serializable { throw new RuntimeException(e); } } + + public List getSupportedApplications() { + List applications = new LinkedList<>(); + for (OtpApp a : allApplications) { + if (a.supports(this)) { + applications.add(a.getName()); + } + } + return applications; + } + + public interface OtpApp { + + String getName(); + + boolean supports(OTPPolicy policy); + } + + public static class GoogleAuthenticator implements OtpApp { + + @Override + public String getName() { + return "Google Authenticator"; + } + + @Override + public boolean supports(OTPPolicy policy) { + if (policy.digits != 6) { + return false; + } + + if (!policy.getAlgorithm().equals("HmacSHA1")) { + return false; + } + + if (policy.getType().equals("totp") && policy.getPeriod() != 30) { + return false; + } + + return true; + } + } + + public static class FreeOTP implements OtpApp { + + @Override + public String getName() { + return "FreeOTP"; + } + + @Override + public boolean supports(OTPPolicy policy) { + return true; + } + } + } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java index de7a078ccd..e85ec7e4e5 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java @@ -46,10 +46,15 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory @Override public void requiredActionChallenge(RequiredActionContext context) { Response challenge = context.form() + .setAttribute("mode", getMode(context)) .createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); context.challenge(challenge); } + private String getMode(RequiredActionContext context) { + return context.getUriInfo().getQueryParameters().getFirst("mode"); + } + @Override public void processAction(RequiredActionContext context) { EventBuilder event = context.getEvent(); @@ -60,12 +65,14 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory if (Validation.isBlank(totp)) { Response challenge = context.form() + .setAttribute("mode", getMode(context)) .setError(Messages.MISSING_TOTP) .createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); context.challenge(challenge); return; } else if (!CredentialValidation.validOTP(context.getRealm(), totp, totpSecret)) { Response challenge = context.form() + .setAttribute("mode", getMode(context)) .setError(Messages.INVALID_TOTP) .createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); context.challenge(challenge); diff --git a/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java b/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java index 5b236fc331..8957f7096d 100755 --- a/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java +++ b/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java @@ -86,6 +86,7 @@ public class FreeMarkerAccountProvider implements AccountProvider { protected KeycloakSession session; protected FreeMarkerUtil freeMarker; protected HttpHeaders headers; + protected Map attributes; protected UriInfo uriInfo; @@ -110,7 +111,11 @@ public class FreeMarkerAccountProvider implements AccountProvider { @Override public Response createResponse(AccountPages page) { - Map attributes = new HashMap(); + Map attributes = new HashMap<>(); + + if (this.attributes != null) { + attributes.putAll(this.attributes); + } Theme theme; try { @@ -156,7 +161,7 @@ public class FreeMarkerAccountProvider implements AccountProvider { switch (page) { case TOTP: - attributes.put("totp", new TotpBean(session, realm, user)); + attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder())); break; case FEDERATED_IDENTITY: attributes.put("federatedIdentity", new AccountFederatedIdentityBean(session, realm, user, uriInfo.getBaseUri(), stateChecker)); @@ -361,6 +366,15 @@ public class FreeMarkerAccountProvider implements AccountProvider { return this; } + @Override + public AccountProvider setAttribute(String key, String value) { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(key, value); + return this; + } + @Override public void close() { } diff --git a/services/src/main/java/org/keycloak/forms/account/freemarker/model/TotpBean.java b/services/src/main/java/org/keycloak/forms/account/freemarker/model/TotpBean.java index 21afcff182..9af43db732 100644 --- a/services/src/main/java/org/keycloak/forms/account/freemarker/model/TotpBean.java +++ b/services/src/main/java/org/keycloak/forms/account/freemarker/model/TotpBean.java @@ -18,25 +18,32 @@ package org.keycloak.forms.account.freemarker.model; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OTPPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.HmacOTP; import org.keycloak.utils.TotpUtils; +import javax.ws.rs.core.UriBuilder; + /** * @author Stian Thorgersen */ public class TotpBean { + private final RealmModel realm; private final String totpSecret; private final String totpSecretEncoded; private final String totpSecretQrCode; private final boolean enabled; + private final UriBuilder uriBuilder; - public TotpBean(KeycloakSession session, RealmModel realm, UserModel user) { + public TotpBean(KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) { + this.uriBuilder = uriBuilder; this.enabled = session.userCredentialManager().isConfiguredFor(realm, user, realm.getOTPPolicy().getType()); + this.realm = realm; this.totpSecret = HmacOTP.generateSecret(20); this.totpSecretEncoded = TotpUtils.encode(totpSecret); this.totpSecretQrCode = TotpUtils.qrCode(totpSecret, realm, user); @@ -58,5 +65,17 @@ public class TotpBean { return totpSecretQrCode; } + public String getManualUrl() { + return uriBuilder.replaceQueryParam("mode", "manual").build().toString(); + } + + public String getQrUrl() { + return uriBuilder.replaceQueryParam("mode", "qr").build().toString(); + } + + public OTPPolicy getPolicy() { + return realm.getOTPPolicy(); + } + } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 05da6b227f..2adbb05b94 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -180,7 +180,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { switch (page) { case LOGIN_CONFIG_TOTP: - attributes.put("totp", new TotpBean(session, realm, user)); + attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder())); break; case LOGIN_UPDATE_PROFILE: UpdateProfileContext userCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/TotpBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/TotpBean.java index 662601816d..76594592ad 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/TotpBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/TotpBean.java @@ -18,22 +18,29 @@ package org.keycloak.forms.login.freemarker.model; import org.keycloak.credential.CredentialModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OTPPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.HmacOTP; import org.keycloak.utils.TotpUtils; +import javax.ws.rs.core.UriBuilder; + /** * @author Stian Thorgersen */ public class TotpBean { + private final RealmModel realm; private final String totpSecret; private final String totpSecretEncoded; private final String totpSecretQrCode; private final boolean enabled; + private UriBuilder uriBuilder; - public TotpBean(KeycloakSession session, RealmModel realm, UserModel user) { + public TotpBean(KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) { + this.realm = realm; + this.uriBuilder = uriBuilder; this.enabled = session.userCredentialManager().isConfiguredFor(realm, user, CredentialModel.OTP); this.totpSecret = HmacOTP.generateSecret(20); this.totpSecretEncoded = TotpUtils.encode(totpSecret); @@ -56,5 +63,18 @@ public class TotpBean { return totpSecretQrCode; } + public String getManualUrl() { + return uriBuilder.replaceQueryParam("mode", "manual").build().toString(); + } + + public String getQrUrl() { + return uriBuilder.replaceQueryParam("mode", "qr").build().toString(); + } + + public OTPPolicy getPolicy() { + return realm.getOTPPolicy(); + } + + } diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java index f9d48bc5b1..68973cfc6d 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java @@ -17,6 +17,7 @@ package org.keycloak.services.resources.account; import org.jboss.logging.Logger; +import org.keycloak.authentication.RequiredActionContext; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Time; import org.keycloak.common.util.UriUtils; @@ -230,6 +231,7 @@ public class AccountFormService extends AbstractSecuredLocalService { @Path("totp") @GET public Response totpPage() { + account.setAttribute("mode", uriInfo.getQueryParameters().getFirst("mode")); return forwardToPage("totp", AccountPages.TOTP); } @@ -442,6 +444,8 @@ public class AccountFormService extends AbstractSecuredLocalService { auth.require(AccountRoles.MANAGE_ACCOUNT); + account.setAttribute("mode", uriInfo.getQueryParameters().getFirst("mode")); + String action = formData.getFirst("submitAction"); if (action != null && action.equals("Cancel")) { setReferrerOnPage(); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AccountTotpPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AccountTotpPage.java index 6527476e02..6e3dd0f124 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AccountTotpPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AccountTotpPage.java @@ -39,6 +39,12 @@ public class AccountTotpPage extends AbstractAccountPage { @FindBy(id = "remove-mobile") private WebElement removeLink; + @FindBy(id = "mode-barcode") + private WebElement barcodeLink; + + @FindBy(id = "mode-manual") + private WebElement manualLink; + private String getPath() { return AccountFormService.totpUrl(UriBuilder.fromUri(getAuthServerRoot())).build("test").toString(); } @@ -64,4 +70,12 @@ public class AccountTotpPage extends AbstractAccountPage { removeLink.click(); } + public void clickManual() { + manualLink.click(); + } + + public void clickBarcode() { + barcodeLink.click(); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java index da289f825a..f5183ac3d8 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginConfigTotpPage.java @@ -33,6 +33,12 @@ public class LoginConfigTotpPage extends AbstractPage { @FindBy(css = "input[type=\"submit\"]") private WebElement submitButton; + @FindBy(id = "mode-barcode") + private WebElement barcodeLink; + + @FindBy(id = "mode-manual") + private WebElement manualLink; + public void configure(String totp) { totpInput.sendKeys(totp); submitButton.click(); @@ -50,4 +56,12 @@ public class LoginConfigTotpPage extends AbstractPage { throw new UnsupportedOperationException(); } + public void clickManual() { + manualLink.click(); + } + + public void clickBarcode() { + barcodeLink.click(); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountFormServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountFormServiceTest.java index abe91d49df..462e10b91e 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountFormServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountFormServiceTest.java @@ -68,7 +68,9 @@ import java.util.Map; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItems; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; /** * @author Stian Thorgersen @@ -770,6 +772,39 @@ public class AccountFormServiceTest extends AbstractTestRealmKeycloakTest { assertFalse(driver.getPageSource().contains("Remove Google")); + String pageSource = driver.getPageSource(); + + assertTrue(pageSource.contains("Install one of the following applications on your mobile")); + assertTrue(pageSource.contains("FreeOTP")); + assertTrue(pageSource.contains("Google Authenticator")); + + assertTrue(pageSource.contains("Open the application and scan the barcode")); + assertFalse(pageSource.contains("Open the application and enter the key")); + + assertTrue(pageSource.contains("Unable to scan?")); + assertFalse(pageSource.contains("Scan barcode?")); + + totpPage.clickManual(); + + pageSource = driver.getPageSource(); + + assertTrue(pageSource.contains("Install one of the following applications on your mobile")); + assertTrue(pageSource.contains("FreeOTP")); + assertTrue(pageSource.contains("Google Authenticator")); + + assertFalse(pageSource.contains("Open the application and scan the barcode")); + assertTrue(pageSource.contains("Open the application and enter the key")); + + assertFalse(pageSource.contains("Unable to scan?")); + assertTrue(pageSource.contains("Scan barcode?")); + + assertTrue(driver.findElement(By.id("kc-totp-secret-key")).getText().matches("[\\w]{4}( [\\w]{4}){7}")); + + assertEquals("Type: Time-based", driver.findElement(By.id("kc-totp-type")).getText()); + assertEquals("Algorithm: HmacSHA1", driver.findElement(By.id("kc-totp-algorithm")).getText()); + assertEquals("Digits: 6", driver.findElement(By.id("kc-totp-digits")).getText()); + assertEquals("Interval: 30", driver.findElement(By.id("kc-totp-period")).getText()); + // Error with false code totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret() + "123")); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java index a06079c0bc..f42a14642a 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java @@ -22,6 +22,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.AuthenticationExecutionModel; @@ -46,11 +47,17 @@ import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.RegisterPage; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.UserBuilder; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + /** * @author Stian Thorgersen */ @@ -123,18 +130,104 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String userId = events.expectRegister("setupTotp", "email@mail.com").assertEvent().getUserId(); - Assert.assertTrue(totpPage.isCurrent()); + assertTrue(totpPage.isCurrent()); totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret())); String authSessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setuptotp").assertEvent() .getDetails().get(Details.CODE_ID); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectLogin().user(userId).session(authSessionId).detail(Details.USERNAME, "setuptotp").assertEvent(); } + @Test + public void setupTotpRegisterManual() { + loginPage.open(); + loginPage.clickRegister(); + registerPage.register("firstName", "lastName", "checkQrCode@mail.com", "checkQrCode", "password", "password"); + + String pageSource = driver.getPageSource(); + + assertTrue(pageSource.contains("Install one of the following applications on your mobile")); + assertTrue(pageSource.contains("FreeOTP")); + assertTrue(pageSource.contains("Google Authenticator")); + + assertTrue(pageSource.contains("Open the application and scan the barcode")); + assertFalse(pageSource.contains("Open the application and enter the key")); + + assertTrue(pageSource.contains("Unable to scan?")); + assertFalse(pageSource.contains("Scan barcode?")); + + totpPage.clickManual(); + + pageSource = driver.getPageSource(); + + assertTrue(pageSource.contains("Install one of the following applications on your mobile")); + assertTrue(pageSource.contains("FreeOTP")); + assertTrue(pageSource.contains("Google Authenticator")); + + assertFalse(pageSource.contains("Open the application and scan the barcode")); + assertTrue(pageSource.contains("Open the application and enter the key")); + + assertFalse(pageSource.contains("Unable to scan?")); + assertTrue(pageSource.contains("Scan barcode?")); + + assertTrue(driver.findElement(By.id("kc-totp-secret-key")).getText().matches("[\\w]{4}( [\\w]{4}){7}")); + + assertEquals("Type: Time-based", driver.findElement(By.id("kc-totp-type")).getText()); + assertEquals("Algorithm: HmacSHA1", driver.findElement(By.id("kc-totp-algorithm")).getText()); + assertEquals("Digits: 6", driver.findElement(By.id("kc-totp-digits")).getText()); + assertEquals("Interval: 30", driver.findElement(By.id("kc-totp-period")).getText()); + + totpPage.clickBarcode(); + + pageSource = driver.getPageSource(); + + assertTrue(pageSource.contains("Install one of the following applications on your mobile")); + assertTrue(pageSource.contains("FreeOTP")); + assertTrue(pageSource.contains("Google Authenticator")); + + assertTrue(pageSource.contains("Open the application and scan the barcode")); + assertFalse(pageSource.contains("Open the application and enter the key")); + + assertTrue(pageSource.contains("Unable to scan?")); + assertFalse(pageSource.contains("Scan barcode?")); + } + + @Test + public void setupTotpModifiedPolicy() { + RealmResource realm = testRealm(); + RealmRepresentation rep = realm.toRepresentation(); + rep.setOtpPolicyDigits(8); + rep.setOtpPolicyType("hotp"); + rep.setOtpPolicyAlgorithm("HmacSHA256"); + realm.update(rep); + try { + loginPage.open(); + loginPage.clickRegister(); + registerPage.register("firstName", "lastName", "setupTotpModifiedPolicy@mail.com", "setupTotpModifiedPolicy", "password", "password"); + + String pageSource = driver.getPageSource(); + + assertTrue(pageSource.contains("FreeOTP")); + assertFalse(pageSource.contains("Google Authenticator")); + + totpPage.clickManual(); + + assertEquals("Type: Counter-based", driver.findElement(By.id("kc-totp-type")).getText()); + assertEquals("Algorithm: HmacSHA256", driver.findElement(By.id("kc-totp-algorithm")).getText()); + assertEquals("Digits: 8", driver.findElement(By.id("kc-totp-digits")).getText()); + assertEquals("Interval: 30", driver.findElement(By.id("kc-totp-period")).getText()); + } finally { + rep.setOtpPolicyDigits(6); + rep.setOtpPolicyType("totp"); + rep.setOtpPolicyAlgorithm("HmacSHA1"); + realm.update(rep); + } + } + @Test public void setupTotpExisting() { loginPage.open(); @@ -149,7 +242,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String authSessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent() .getDetails().get(Details.CODE_ID); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); EventRepresentation loginEvent = events.expectLogin().session(authSessionId).assertEvent(); @@ -162,7 +255,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String src = driver.getPageSource(); loginTotpPage.login(totp.generateTOTP(totpSecret)); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectLogin().assertEvent(); } @@ -185,7 +278,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { totpPage.configure(totp.generateTOTP(totpCode)); // After totp config, user should be on the app page - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setuptotp2").assertEvent(); @@ -202,7 +295,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { // Totp is already configured, thus one-time password is needed, login page should be loaded String uri = driver.getCurrentUrl(); String src = driver.getPageSource(); - Assert.assertTrue(loginPage.isCurrent()); + assertTrue(loginPage.isCurrent()); Assert.assertFalse(totpPage.isCurrent()); // Login with one-time password @@ -234,7 +327,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setupTotp2").assertEvent() .getDetails().get(Details.CODE_ID); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectLogin().user(userId).session(sessionId).detail(Details.USERNAME, "setupTotp2").assertEvent(); } @@ -266,7 +359,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent() .getDetails().get(Details.CODE_ID); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); EventRepresentation loginEvent = events.expectLogin().session(sessionId).assertEvent(); @@ -278,10 +371,10 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { loginPage.login("test-user@localhost", "password"); String src = driver.getPageSource(); String token = timeBased.generateTOTP(totpSecret); - Assert.assertEquals(8, token.length()); + assertEquals(8, token.length()); loginTotpPage.login(token); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectLogin().assertEvent(); @@ -318,7 +411,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent() .getDetails().get(Details.CODE_ID); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); EventRepresentation loginEvent = events.expectLogin().session(sessionId).assertEvent(); @@ -331,7 +424,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { String token = otpgen.generateHOTP(totpSecret, 1); loginTotpPage.login(token); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectLogin().assertEvent(); @@ -356,7 +449,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { loginTotpPage.assertCurrent(); loginTotpPage.login(token); - Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); events.expectLogin().assertEvent(); diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index e98874b43e..b943edcfdc 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -98,10 +98,23 @@ revoke=Revoke Grant configureAuthenticators=Configured Authenticators mobile=Mobile -totpStep1=Install FreeOTP or Google Authenticator on your device. Both applications are available in Google Play and Apple App Store. -totpStep2=Open the application and scan the barcode or enter the key. +totpStep1=Install one of the following applications on your mobile +totpStep2=Open the application and scan the barcode totpStep3=Enter the one-time code provided by the application and click Save to finish the setup. +totpManualStep2=Open the application and enter the key +totpManualStep3=Use the following configuration values if the application allows setting them +totpUnableToScan=Unable to scan? +totpScanBarcode=Scan barcode? + +totp.totp=Time-based +totp.hotp=Counter-based + +totpType=Type +totpAlgorithm=Algorithm +totpDigits=Digits +totpInterval=Interval + missingUsernameMessage=Please specify username. missingFirstNameMessage=Please specify first name. invalidEmailMessage=Invalid email address. diff --git a/themes/src/main/resources/theme/base/account/totp.ftl b/themes/src/main/resources/theme/base/account/totp.ftl index 7115938535..468260e523 100755 --- a/themes/src/main/resources/theme/base/account/totp.ftl +++ b/themes/src/main/resources/theme/base/account/totp.ftl @@ -30,13 +30,37 @@
  1. -

    ${msg("totpStep1")?no_esc}

    -
  2. -
  3. -

    ${msg("totpStep2")}

    -

    Figure: Barcode

    -

    ${totp.totpSecretEncoded}

    +

    ${msg("totpStep1")}

    + +
      + <#list totp.policy.supportedApplications as app> +
    • ${app}
    • + +
  4. + + <#if mode?? && mode = "manual"> +
  5. +

    ${msg("totpManualStep2")}

    +

    ${totp.totpSecretEncoded}

    +

    ${msg("totpScanBarcode")}

    +
  6. +
  7. +

    ${msg("totpManualStep3")}

    +
      +
    • ${msg("totpType")}: ${msg("totp." + totp.policy.type)}
    • +
    • ${msg("totpAlgorithm")}: ${totp.policy.algorithm}
    • +
    • ${msg("totpDigits")}: ${totp.policy.digits}
    • +
    • ${msg("totpInterval")}: ${totp.policy.period}
    • +
    +
  8. + <#else> +
  9. +

    ${msg("totpStep2")}

    +

    Figure: Barcode

    +

    ${msg("totpUnableToScan")}

    +
  10. +
  11. ${msg("totpStep3")}

  12. diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index dd69970d80..443a5c89b4 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -891,6 +891,8 @@ initial-counter=Initial Counter otp.initial-counter.tooltip=What should the initial counter value be? 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 table-of-password-policies=Table of Password Policies add-policy.placeholder=Add policy... policy-type=Policy Type diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index b54ea0f333..283fc6b9f6 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -338,7 +338,7 @@ module.controller('RealmDetailCtrl', function($scope, Current, Realm, realm, ser }; }); -function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, url) { +function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, url) { $scope.realm = angular.copy(realm); $scope.serverInfo = serverInfo; $scope.registrationAllowed = $scope.realm.registrationAllowed; @@ -359,16 +359,7 @@ function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $l $scope.changed = false; console.log('oldCopy.realm - ' + oldCopy.realm); Realm.update({ id : oldCopy.realm}, realmCopy, function () { - var data = Realm.query(function () { - Current.realms = data; - for (var i = 0; i < Current.realms.length; i++) { - if (Current.realms[i].realm == realmCopy.realm) { - Current.realm = Current.realms[i]; - oldCopy = angular.copy($scope.realm); - } - } - }); - $location.url(url); + $route.reload(); Notifications.success("Your changes have been saved to the realm."); $scope.registrationAllowed = $scope.realm.registrationAllowed; }); @@ -380,17 +371,16 @@ function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $l }; $scope.cancel = function() { - //$location.url("/realms"); - window.history.back(); + $route.reload(); }; } -module.controller('DefenseHeadersCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications) { - genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/defense/headers"); +module.controller('DefenseHeadersCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { + genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/defense/headers"); }); -module.controller('RealmLoginSettingsCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications) { +module.controller('RealmLoginSettingsCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { // KEYCLOAK-5474: Make sure duplicateEmailsAllowed is disabled if loginWithEmailAllowed $scope.$watch('realm.loginWithEmailAllowed', function() { if ($scope.realm.loginWithEmailAllowed) { @@ -398,18 +388,18 @@ module.controller('RealmLoginSettingsCtrl', function($scope, Current, Realm, rea } }); - genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/login-settings"); + genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/login-settings"); }); -module.controller('RealmOtpPolicyCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications) { +module.controller('RealmOtpPolicyCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { $scope.optionsDigits = [ 6, 8 ]; - genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/otp-policy"); + genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/otp-policy"); }); -module.controller('RealmThemeCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications) { - genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/theme-settings"); +module.controller('RealmThemeCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { + genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/theme-settings"); $scope.supportedLocalesOptions = { 'multiple' : true, @@ -1975,7 +1965,7 @@ module.controller('IdentityProviderMapperCreateCtrl', function($scope, realm, id }); -module.controller('RealmFlowBindingCtrl', function($scope, flows, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications) { +module.controller('RealmFlowBindingCtrl', function($scope, flows, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { $scope.flows = []; $scope.clientFlows = []; for (var i=0 ; i{{:: 'otp-token-period.tooltip' | translate}} +
    + +
    + {{realm.otpSupportedApplications.join(', ')}} +
    + {{:: 'otp-supported-applications.tooltip' | translate}} +
    +
    diff --git a/themes/src/main/resources/theme/base/login/login-config-totp.ftl b/themes/src/main/resources/theme/base/login/login-config-totp.ftl index ea2d6b055f..24383ecd5b 100755 --- a/themes/src/main/resources/theme/base/login/login-config-totp.ftl +++ b/themes/src/main/resources/theme/base/login/login-config-totp.ftl @@ -5,19 +5,46 @@ <#elseif section = "header"> ${msg("loginTotpTitle")} <#elseif section = "form"> -
      -
    1. -

      ${msg("loginTotpStep1")?no_esc}

      + + +
        +
      1. +

        ${msg("loginTotpStep1")}

        + +
          + <#list totp.policy.supportedApplications as app> +
        • ${app}
        • + +
      2. -
      3. -

        ${msg("loginTotpStep2")}

        - Figure: Barcode
        - ${totp.totpSecretEncoded} -
      4. -
      5. -

        ${msg("loginTotpStep3")}

        + + <#if mode?? && mode = "manual"> +
      6. +

        ${msg("loginTotpManualStep2")}

        +

        ${totp.totpSecretEncoded}

        +

        ${msg("loginTotpScanBarcode")}

        +
      7. +
      8. +

        ${msg("loginTotpManualStep3")}

        +
          +
        • ${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}
        • +
        • ${msg("loginTotpAlgorithm")}: ${totp.policy.algorithm}
        • +
        • ${msg("loginTotpDigits")}: ${totp.policy.digits}
        • +
        • ${msg("loginTotpInterval")}: ${totp.policy.period}
        • +
        +
      9. + <#else> +
      10. +

        ${msg("loginTotpStep2")}

        + Figure: Barcode
        +

        ${msg("loginTotpUnableToScan")}

        +
      11. + +
      12. +

        ${msg("loginTotpStep3")}

      +
      diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 03f2c4773a..7c5a22db2a 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -68,10 +68,22 @@ country=Country emailVerified=Email verified gssDelegationCredential=GSS Delegation Credential -loginTotpStep1=Install FreeOTP or Google Authenticator on your mobile. Both applications are available in Google Play and Apple App Store. -loginTotpStep2=Open the application and scan the barcode or enter the key +loginTotpStep1=Install one of the following applications on your mobile +loginTotpStep2=Open the application and scan the barcode loginTotpStep3=Enter the one-time code provided by the application and click Submit to finish the setup +loginTotpManualStep2=Open the application and enter the key +loginTotpManualStep3=Use the following configuration values if the application allows setting them +loginTotpUnableToScan=Unable to scan? +loginTotpScanBarcode=Scan barcode? loginTotpOneTime=One-time code +loginTotpType=Type +loginTotpAlgorithm=Algorithm +loginTotpDigits=Digits +loginTotpInterval=Interval + +loginTotp.totp=Time-based +loginTotp.hotp=Counter-based + oauthGrantRequest=Do you grant these access privileges? inResource=in diff --git a/themes/src/main/resources/theme/keycloak/account/resources/css/account.css b/themes/src/main/resources/theme/keycloak/account/resources/css/account.css index 3014bca5aa..3878e43ac8 100644 --- a/themes/src/main/resources/theme/keycloak/account/resources/css/account.css +++ b/themes/src/main/resources/theme/keycloak/account/resources/css/account.css @@ -267,3 +267,11 @@ hr + .form-horizontal { .kc-dropdown:hover ul{ display:block; } + + +#kc-totp-secret-key { + border: 1px solid #eee; + font-size: 16px; + padding: 10px; + margin: 50px 0; +} \ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css index 55deb326e8..3c1a31a993 100644 --- a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css +++ b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css @@ -156,6 +156,13 @@ ol#kc-totp-settings li:first-of-type { max-height:150px; } +#kc-totp-secret-key { + background-color: #fff; + color: #333333; + font-size: 16px; + padding: 10px; +} + /* OAuth */ #kc-oauth h3 {