KEYCLOAK-2120 Added manual setup page for OTP

This commit is contained in:
stianst 2017-12-15 14:25:21 +01:00 committed by Stian Thorgersen
parent 118e998570
commit b303acaaba
23 changed files with 444 additions and 61 deletions

View file

@ -99,6 +99,7 @@ public class RealmRepresentation {
protected Integer otpPolicyDigits; protected Integer otpPolicyDigits;
protected Integer otpPolicyLookAheadWindow; protected Integer otpPolicyLookAheadWindow;
protected Integer otpPolicyPeriod; protected Integer otpPolicyPeriod;
protected List<String> otpSupportedApplications;
protected List<UserRepresentation> users; protected List<UserRepresentation> users;
protected List<UserRepresentation> federatedUsers; protected List<UserRepresentation> federatedUsers;
@ -854,6 +855,14 @@ public class RealmRepresentation {
this.otpPolicyPeriod = otpPolicyPeriod; this.otpPolicyPeriod = otpPolicyPeriod;
} }
public List<String> getOtpSupportedApplications() {
return otpSupportedApplications;
}
public void setOtpSupportedApplications(List<String> otpSupportedApplications) {
this.otpSupportedApplications = otpSupportedApplications;
}
public String getBrowserFlow() { public String getBrowserFlow() {
return browserFlow; return browserFlow;
} }

View file

@ -66,4 +66,6 @@ public interface AccountProvider extends Provider {
AccountProvider setStateChecker(String stateChecker); AccountProvider setStateChecker(String stateChecker);
AccountProvider setFeatures(boolean social, boolean events, boolean passwordUpdateSupported); AccountProvider setFeatures(boolean social, boolean events, boolean passwordUpdateSupported);
AccountProvider setAttribute(String key, String value);
} }

View file

@ -282,6 +282,7 @@ public class ModelToRepresentation {
rep.setOtpPolicyInitialCounter(otpPolicy.getInitialCounter()); rep.setOtpPolicyInitialCounter(otpPolicy.getInitialCounter());
rep.setOtpPolicyType(otpPolicy.getType()); rep.setOtpPolicyType(otpPolicy.getType());
rep.setOtpPolicyLookAheadWindow(otpPolicy.getLookAheadWindow()); rep.setOtpPolicyLookAheadWindow(otpPolicy.getLookAheadWindow());
rep.setOtpSupportedApplications(otpPolicy.getSupportedApplications());
if (realm.getBrowserFlow() != null) rep.setBrowserFlow(realm.getBrowserFlow().getAlias()); if (realm.getBrowserFlow() != null) rep.setBrowserFlow(realm.getBrowserFlow().getAlias());
if (realm.getRegistrationFlow() != null) rep.setRegistrationFlow(realm.getRegistrationFlow().getAlias()); if (realm.getRegistrationFlow() != null) rep.setRegistrationFlow(realm.getRegistrationFlow().getAlias());
if (realm.getDirectGrantFlow() != null) rep.setDirectGrantFlow(realm.getDirectGrantFlow().getAlias()); if (realm.getDirectGrantFlow() != null) rep.setDirectGrantFlow(realm.getDirectGrantFlow().getAlias());

View file

@ -25,6 +25,8 @@ import java.io.Serializable;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -44,6 +46,8 @@ public class OTPPolicy implements Serializable {
private static final Map<String, String> algToKeyUriAlg = new HashMap<>(); private static final Map<String, String> algToKeyUriAlg = new HashMap<>();
private static final OtpApp[] allApplications = new OtpApp[] { new FreeOTP(), new GoogleAuthenticator() };
static { static {
algToKeyUriAlg.put(HmacOTP.HMAC_SHA1, "SHA1"); algToKeyUriAlg.put(HmacOTP.HMAC_SHA1, "SHA1");
algToKeyUriAlg.put(HmacOTP.HMAC_SHA256, "SHA256"); algToKeyUriAlg.put(HmacOTP.HMAC_SHA256, "SHA256");
@ -151,4 +155,60 @@ public class OTPPolicy implements Serializable {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
public List<String> getSupportedApplications() {
List<String> 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;
}
}
} }

View file

@ -46,10 +46,15 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
@Override @Override
public void requiredActionChallenge(RequiredActionContext context) { public void requiredActionChallenge(RequiredActionContext context) {
Response challenge = context.form() Response challenge = context.form()
.setAttribute("mode", getMode(context))
.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); .createResponse(UserModel.RequiredAction.CONFIGURE_TOTP);
context.challenge(challenge); context.challenge(challenge);
} }
private String getMode(RequiredActionContext context) {
return context.getUriInfo().getQueryParameters().getFirst("mode");
}
@Override @Override
public void processAction(RequiredActionContext context) { public void processAction(RequiredActionContext context) {
EventBuilder event = context.getEvent(); EventBuilder event = context.getEvent();
@ -60,12 +65,14 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
if (Validation.isBlank(totp)) { if (Validation.isBlank(totp)) {
Response challenge = context.form() Response challenge = context.form()
.setAttribute("mode", getMode(context))
.setError(Messages.MISSING_TOTP) .setError(Messages.MISSING_TOTP)
.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); .createResponse(UserModel.RequiredAction.CONFIGURE_TOTP);
context.challenge(challenge); context.challenge(challenge);
return; return;
} else if (!CredentialValidation.validOTP(context.getRealm(), totp, totpSecret)) { } else if (!CredentialValidation.validOTP(context.getRealm(), totp, totpSecret)) {
Response challenge = context.form() Response challenge = context.form()
.setAttribute("mode", getMode(context))
.setError(Messages.INVALID_TOTP) .setError(Messages.INVALID_TOTP)
.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP); .createResponse(UserModel.RequiredAction.CONFIGURE_TOTP);
context.challenge(challenge); context.challenge(challenge);

View file

@ -86,6 +86,7 @@ public class FreeMarkerAccountProvider implements AccountProvider {
protected KeycloakSession session; protected KeycloakSession session;
protected FreeMarkerUtil freeMarker; protected FreeMarkerUtil freeMarker;
protected HttpHeaders headers; protected HttpHeaders headers;
protected Map<String, Object> attributes;
protected UriInfo uriInfo; protected UriInfo uriInfo;
@ -110,7 +111,11 @@ public class FreeMarkerAccountProvider implements AccountProvider {
@Override @Override
public Response createResponse(AccountPages page) { public Response createResponse(AccountPages page) {
Map<String, Object> attributes = new HashMap<String, Object>(); Map<String, Object> attributes = new HashMap<>();
if (this.attributes != null) {
attributes.putAll(this.attributes);
}
Theme theme; Theme theme;
try { try {
@ -156,7 +161,7 @@ public class FreeMarkerAccountProvider implements AccountProvider {
switch (page) { switch (page) {
case TOTP: case TOTP:
attributes.put("totp", new TotpBean(session, realm, user)); attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder()));
break; break;
case FEDERATED_IDENTITY: case FEDERATED_IDENTITY:
attributes.put("federatedIdentity", new AccountFederatedIdentityBean(session, realm, user, uriInfo.getBaseUri(), stateChecker)); attributes.put("federatedIdentity", new AccountFederatedIdentityBean(session, realm, user, uriInfo.getBaseUri(), stateChecker));
@ -361,6 +366,15 @@ public class FreeMarkerAccountProvider implements AccountProvider {
return this; return this;
} }
@Override
public AccountProvider setAttribute(String key, String value) {
if (attributes == null) {
attributes = new HashMap<>();
}
attributes.put(key, value);
return this;
}
@Override @Override
public void close() { public void close() {
} }

View file

@ -18,25 +18,32 @@
package org.keycloak.forms.account.freemarker.model; package org.keycloak.forms.account.freemarker.model;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.HmacOTP; import org.keycloak.models.utils.HmacOTP;
import org.keycloak.utils.TotpUtils; import org.keycloak.utils.TotpUtils;
import javax.ws.rs.core.UriBuilder;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
public class TotpBean { public class TotpBean {
private final RealmModel realm;
private final String totpSecret; private final String totpSecret;
private final String totpSecretEncoded; private final String totpSecretEncoded;
private final String totpSecretQrCode; private final String totpSecretQrCode;
private final boolean enabled; 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.enabled = session.userCredentialManager().isConfiguredFor(realm, user, realm.getOTPPolicy().getType());
this.realm = realm;
this.totpSecret = HmacOTP.generateSecret(20); this.totpSecret = HmacOTP.generateSecret(20);
this.totpSecretEncoded = TotpUtils.encode(totpSecret); this.totpSecretEncoded = TotpUtils.encode(totpSecret);
this.totpSecretQrCode = TotpUtils.qrCode(totpSecret, realm, user); this.totpSecretQrCode = TotpUtils.qrCode(totpSecret, realm, user);
@ -58,5 +65,17 @@ public class TotpBean {
return totpSecretQrCode; 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();
}
} }

View file

@ -180,7 +180,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
switch (page) { switch (page) {
case LOGIN_CONFIG_TOTP: case LOGIN_CONFIG_TOTP:
attributes.put("totp", new TotpBean(session, realm, user)); attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder()));
break; break;
case LOGIN_UPDATE_PROFILE: case LOGIN_UPDATE_PROFILE:
UpdateProfileContext userCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR); UpdateProfileContext userCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR);

View file

@ -18,22 +18,29 @@ package org.keycloak.forms.login.freemarker.model;
import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.HmacOTP; import org.keycloak.models.utils.HmacOTP;
import org.keycloak.utils.TotpUtils; import org.keycloak.utils.TotpUtils;
import javax.ws.rs.core.UriBuilder;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
public class TotpBean { public class TotpBean {
private final RealmModel realm;
private final String totpSecret; private final String totpSecret;
private final String totpSecretEncoded; private final String totpSecretEncoded;
private final String totpSecretQrCode; private final String totpSecretQrCode;
private final boolean enabled; 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.enabled = session.userCredentialManager().isConfiguredFor(realm, user, CredentialModel.OTP);
this.totpSecret = HmacOTP.generateSecret(20); this.totpSecret = HmacOTP.generateSecret(20);
this.totpSecretEncoded = TotpUtils.encode(totpSecret); this.totpSecretEncoded = TotpUtils.encode(totpSecret);
@ -56,5 +63,18 @@ public class TotpBean {
return totpSecretQrCode; 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();
}
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.services.resources.account; package org.keycloak.services.resources.account;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.common.util.UriUtils; import org.keycloak.common.util.UriUtils;
@ -230,6 +231,7 @@ public class AccountFormService extends AbstractSecuredLocalService {
@Path("totp") @Path("totp")
@GET @GET
public Response totpPage() { public Response totpPage() {
account.setAttribute("mode", uriInfo.getQueryParameters().getFirst("mode"));
return forwardToPage("totp", AccountPages.TOTP); return forwardToPage("totp", AccountPages.TOTP);
} }
@ -442,6 +444,8 @@ public class AccountFormService extends AbstractSecuredLocalService {
auth.require(AccountRoles.MANAGE_ACCOUNT); auth.require(AccountRoles.MANAGE_ACCOUNT);
account.setAttribute("mode", uriInfo.getQueryParameters().getFirst("mode"));
String action = formData.getFirst("submitAction"); String action = formData.getFirst("submitAction");
if (action != null && action.equals("Cancel")) { if (action != null && action.equals("Cancel")) {
setReferrerOnPage(); setReferrerOnPage();

View file

@ -39,6 +39,12 @@ public class AccountTotpPage extends AbstractAccountPage {
@FindBy(id = "remove-mobile") @FindBy(id = "remove-mobile")
private WebElement removeLink; private WebElement removeLink;
@FindBy(id = "mode-barcode")
private WebElement barcodeLink;
@FindBy(id = "mode-manual")
private WebElement manualLink;
private String getPath() { private String getPath() {
return AccountFormService.totpUrl(UriBuilder.fromUri(getAuthServerRoot())).build("test").toString(); return AccountFormService.totpUrl(UriBuilder.fromUri(getAuthServerRoot())).build("test").toString();
} }
@ -64,4 +70,12 @@ public class AccountTotpPage extends AbstractAccountPage {
removeLink.click(); removeLink.click();
} }
public void clickManual() {
manualLink.click();
}
public void clickBarcode() {
barcodeLink.click();
}
} }

View file

@ -33,6 +33,12 @@ public class LoginConfigTotpPage extends AbstractPage {
@FindBy(css = "input[type=\"submit\"]") @FindBy(css = "input[type=\"submit\"]")
private WebElement submitButton; private WebElement submitButton;
@FindBy(id = "mode-barcode")
private WebElement barcodeLink;
@FindBy(id = "mode-manual")
private WebElement manualLink;
public void configure(String totp) { public void configure(String totp) {
totpInput.sendKeys(totp); totpInput.sendKeys(totp);
submitButton.click(); submitButton.click();
@ -50,4 +56,12 @@ public class LoginConfigTotpPage extends AbstractPage {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
public void clickManual() {
manualLink.click();
}
public void clickBarcode() {
barcodeLink.click();
}
} }

View file

@ -68,7 +68,9 @@ import java.util.Map;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.hasItems;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -770,6 +772,39 @@ public class AccountFormServiceTest extends AbstractTestRealmKeycloakTest {
assertFalse(driver.getPageSource().contains("Remove Google")); 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 // Error with false code
totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret() + "123")); totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret() + "123"));

View file

@ -22,6 +22,7 @@ import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel; 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.pages.RegisterPage;
import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
@ -123,18 +130,104 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
String userId = events.expectRegister("setupTotp", "email@mail.com").assertEvent().getUserId(); String userId = events.expectRegister("setupTotp", "email@mail.com").assertEvent().getUserId();
Assert.assertTrue(totpPage.isCurrent()); assertTrue(totpPage.isCurrent());
totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret())); totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret()));
String authSessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setuptotp").assertEvent() String authSessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setuptotp").assertEvent()
.getDetails().get(Details.CODE_ID); .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(); 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 @Test
public void setupTotpExisting() { public void setupTotpExisting() {
loginPage.open(); loginPage.open();
@ -149,7 +242,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
String authSessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent() String authSessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent()
.getDetails().get(Details.CODE_ID); .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(); EventRepresentation loginEvent = events.expectLogin().session(authSessionId).assertEvent();
@ -162,7 +255,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
String src = driver.getPageSource(); String src = driver.getPageSource();
loginTotpPage.login(totp.generateTOTP(totpSecret)); loginTotpPage.login(totp.generateTOTP(totpSecret));
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().assertEvent(); events.expectLogin().assertEvent();
} }
@ -185,7 +278,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
totpPage.configure(totp.generateTOTP(totpCode)); totpPage.configure(totp.generateTOTP(totpCode));
// After totp config, user should be on the app page // 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(); 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 // Totp is already configured, thus one-time password is needed, login page should be loaded
String uri = driver.getCurrentUrl(); String uri = driver.getCurrentUrl();
String src = driver.getPageSource(); String src = driver.getPageSource();
Assert.assertTrue(loginPage.isCurrent()); assertTrue(loginPage.isCurrent());
Assert.assertFalse(totpPage.isCurrent()); Assert.assertFalse(totpPage.isCurrent());
// Login with one-time password // 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() String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setupTotp2").assertEvent()
.getDetails().get(Details.CODE_ID); .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(); 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() String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent()
.getDetails().get(Details.CODE_ID); .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(); EventRepresentation loginEvent = events.expectLogin().session(sessionId).assertEvent();
@ -278,10 +371,10 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
loginPage.login("test-user@localhost", "password"); loginPage.login("test-user@localhost", "password");
String src = driver.getPageSource(); String src = driver.getPageSource();
String token = timeBased.generateTOTP(totpSecret); String token = timeBased.generateTOTP(totpSecret);
Assert.assertEquals(8, token.length()); assertEquals(8, token.length());
loginTotpPage.login(token); loginTotpPage.login(token);
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().assertEvent(); events.expectLogin().assertEvent();
@ -318,7 +411,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent() String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent()
.getDetails().get(Details.CODE_ID); .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(); EventRepresentation loginEvent = events.expectLogin().session(sessionId).assertEvent();
@ -331,7 +424,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
String token = otpgen.generateHOTP(totpSecret, 1); String token = otpgen.generateHOTP(totpSecret, 1);
loginTotpPage.login(token); loginTotpPage.login(token);
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().assertEvent(); events.expectLogin().assertEvent();
@ -356,7 +449,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest {
loginTotpPage.assertCurrent(); loginTotpPage.assertCurrent();
loginTotpPage.login(token); loginTotpPage.login(token);
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().assertEvent(); events.expectLogin().assertEvent();

View file

@ -98,10 +98,23 @@ revoke=Revoke Grant
configureAuthenticators=Configured Authenticators configureAuthenticators=Configured Authenticators
mobile=Mobile mobile=Mobile
totpStep1=Install <a href="https://freeotp.github.io/" target="_blank">FreeOTP</a> or Google Authenticator on your device. Both applications are available in <a href="https://play.google.com">Google Play</a> and Apple App Store. totpStep1=Install one of the following applications on your mobile
totpStep2=Open the application and scan the barcode or enter the key. 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. 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. missingUsernameMessage=Please specify username.
missingFirstNameMessage=Please specify first name. missingFirstNameMessage=Please specify first name.
invalidEmailMessage=Invalid email address. invalidEmailMessage=Invalid email address.

View file

@ -30,13 +30,37 @@
<ol> <ol>
<li> <li>
<p>${msg("totpStep1")?no_esc}</p> <p>${msg("totpStep1")}</p>
</li>
<li> <ul>
<p>${msg("totpStep2")}</p> <#list totp.policy.supportedApplications as app>
<p><img src="data:image/png;base64, ${totp.totpSecretQrCode}" alt="Figure: Barcode"></p> <li>${app}</li>
<p><span class="code">${totp.totpSecretEncoded}</span></p> </#list>
</ul>
</li> </li>
<#if mode?? && mode = "manual">
<li>
<p>${msg("totpManualStep2")}</p>
<p><span id="kc-totp-secret-key">${totp.totpSecretEncoded}</span></p>
<p><a href="${totp.qrUrl}" id="mode-barcode">${msg("totpScanBarcode")}</a></p>
</li>
<li>
<p>${msg("totpManualStep3")}</p>
<ul>
<li id="kc-totp-type">${msg("totpType")}: ${msg("totp." + totp.policy.type)}</li>
<li id="kc-totp-algorithm">${msg("totpAlgorithm")}: ${totp.policy.algorithm}</li>
<li id="kc-totp-digits">${msg("totpDigits")}: ${totp.policy.digits}</li>
<li id="kc-totp-period">${msg("totpInterval")}: ${totp.policy.period}</li>
</ul>
</li>
<#else>
<li>
<p>${msg("totpStep2")}</p>
<p><img src="data:image/png;base64, ${totp.totpSecretQrCode}" alt="Figure: Barcode"></p>
<p><a href="${totp.manualUrl}" id="mode-manual">${msg("totpUnableToScan")}</a></p>
</li>
</#if>
<li> <li>
<p>${msg("totpStep3")}</p> <p>${msg("totpStep3")}</p>
</li> </li>

View file

@ -891,6 +891,8 @@ initial-counter=Initial Counter
otp.initial-counter.tooltip=What should the initial counter value be? otp.initial-counter.tooltip=What should the initial counter value be?
otp-token-period=OTP Token Period otp-token-period=OTP Token Period
otp-token-period.tooltip=How many seconds should an OTP token be valid? Defaults to 30 seconds. 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 table-of-password-policies=Table of Password Policies
add-policy.placeholder=Add policy... add-policy.placeholder=Add policy...
policy-type=Policy Type policy-type=Policy Type

View file

@ -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.realm = angular.copy(realm);
$scope.serverInfo = serverInfo; $scope.serverInfo = serverInfo;
$scope.registrationAllowed = $scope.realm.registrationAllowed; $scope.registrationAllowed = $scope.realm.registrationAllowed;
@ -359,16 +359,7 @@ function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $l
$scope.changed = false; $scope.changed = false;
console.log('oldCopy.realm - ' + oldCopy.realm); console.log('oldCopy.realm - ' + oldCopy.realm);
Realm.update({ id : oldCopy.realm}, realmCopy, function () { Realm.update({ id : oldCopy.realm}, realmCopy, function () {
var data = Realm.query(function () { $route.reload();
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);
Notifications.success("Your changes have been saved to the realm."); Notifications.success("Your changes have been saved to the realm.");
$scope.registrationAllowed = $scope.realm.registrationAllowed; $scope.registrationAllowed = $scope.realm.registrationAllowed;
}); });
@ -380,17 +371,16 @@ function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $l
}; };
$scope.cancel = function() { $scope.cancel = function() {
//$location.url("/realms"); $route.reload();
window.history.back();
}; };
} }
module.controller('DefenseHeadersCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications) { module.controller('DefenseHeadersCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) {
genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/defense/headers"); 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 // KEYCLOAK-5474: Make sure duplicateEmailsAllowed is disabled if loginWithEmailAllowed
$scope.$watch('realm.loginWithEmailAllowed', function() { $scope.$watch('realm.loginWithEmailAllowed', function() {
if ($scope.realm.loginWithEmailAllowed) { 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 ]; $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) { module.controller('RealmThemeCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) {
genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/theme-settings"); genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/theme-settings");
$scope.supportedLocalesOptions = { $scope.supportedLocalesOptions = {
'multiple' : true, '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.flows = [];
$scope.clientFlows = []; $scope.clientFlows = [];
for (var i=0 ; i<flows.length ; i++) { for (var i=0 ; i<flows.length ; i++) {
@ -1988,7 +1978,7 @@ module.controller('RealmFlowBindingCtrl', function($scope, flows, Current, Realm
$scope.profileInfo = serverInfo.profileInfo; $scope.profileInfo = serverInfo.profileInfo;
genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/flow-bindings"); genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/flow-bindings");
}); });

View file

@ -65,6 +65,14 @@
<kc-tooltip>{{:: 'otp-token-period.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'otp-token-period.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group">
<label class="col-md-2 control-label">{{:: 'otp-supported-applications' | translate}}</label>
<div class="col-md-6">
{{realm.otpSupportedApplications.join(', ')}}
</div>
<kc-tooltip>{{:: 'otp-supported-applications.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="access.manageRealm"> <div class="form-group" data-ng-show="access.manageRealm">
<div class="col-md-10 col-md-offset-2"> <div class="col-md-10 col-md-offset-2">

View file

@ -5,19 +5,46 @@
<#elseif section = "header"> <#elseif section = "header">
${msg("loginTotpTitle")} ${msg("loginTotpTitle")}
<#elseif section = "form"> <#elseif section = "form">
<ol id="kc-totp-settings">
<li>
<p>${msg("loginTotpStep1")?no_esc}</p> <ol id="kc-totp-settings">
<li>
<p>${msg("loginTotpStep1")}</p>
<ul id="kc-totp-supported-apps">
<#list totp.policy.supportedApplications as app>
<li>${app}</li>
</#list>
</ul>
</li> </li>
<li>
<p>${msg("loginTotpStep2")}</p> <#if mode?? && mode = "manual">
<img id="kc-totp-secret-qr-code" src="data:image/png;base64, ${totp.totpSecretQrCode}" alt="Figure: Barcode"><br/> <li>
<span class="code">${totp.totpSecretEncoded}</span> <p>${msg("loginTotpManualStep2")}</p>
</li> <p><span id="kc-totp-secret-key">${totp.totpSecretEncoded}</span></p>
<li> <p><a href="${totp.qrUrl}" id="mode-barcode">${msg("loginTotpScanBarcode")}</a></p>
<p>${msg("loginTotpStep3")}</p> </li>
<li>
<p>${msg("loginTotpManualStep3")}</p>
<ul>
<li id="kc-totp-type">${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}</li>
<li id="kc-totp-algorithm">${msg("loginTotpAlgorithm")}: ${totp.policy.algorithm}</li>
<li id="kc-totp-digits">${msg("loginTotpDigits")}: ${totp.policy.digits}</li>
<li id="kc-totp-period">${msg("loginTotpInterval")}: ${totp.policy.period}</li>
</ul>
</li>
<#else>
<li>
<p>${msg("loginTotpStep2")}</p>
<img id="kc-totp-secret-qr-code" src="data:image/png;base64, ${totp.totpSecretQrCode}" alt="Figure: Barcode"><br/>
<p><a href="${totp.manualUrl}" id="mode-manual">${msg("loginTotpUnableToScan")}</a></p>
</li>
</#if>
<li>
<p>${msg("loginTotpStep3")}</p>
</li> </li>
</ol> </ol>
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-totp-settings-form" method="post"> <form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-totp-settings-form" method="post">
<div class="${properties.kcFormGroupClass!}"> <div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcInputWrapperClass!}"> <div class="${properties.kcInputWrapperClass!}">

View file

@ -68,10 +68,22 @@ country=Country
emailVerified=Email verified emailVerified=Email verified
gssDelegationCredential=GSS Delegation Credential gssDelegationCredential=GSS Delegation Credential
loginTotpStep1=Install <a href="https://freeotp.github.io/" target="_blank">FreeOTP</a> or Google Authenticator on your mobile. Both applications are available in <a href="https://play.google.com">Google Play</a> and Apple App Store. loginTotpStep1=Install one of the following applications on your mobile
loginTotpStep2=Open the application and scan the barcode or enter the key 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 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 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? oauthGrantRequest=Do you grant these access privileges?
inResource=in inResource=in

View file

@ -267,3 +267,11 @@ hr + .form-horizontal {
.kc-dropdown:hover ul{ .kc-dropdown:hover ul{
display:block; display:block;
} }
#kc-totp-secret-key {
border: 1px solid #eee;
font-size: 16px;
padding: 10px;
margin: 50px 0;
}

View file

@ -156,6 +156,13 @@ ol#kc-totp-settings li:first-of-type {
max-height:150px; max-height:150px;
} }
#kc-totp-secret-key {
background-color: #fff;
color: #333333;
font-size: 16px;
padding: 10px;
}
/* OAuth */ /* OAuth */
#kc-oauth h3 { #kc-oauth h3 {