[KEYCLOAK-12168] Various setup TOTP screen usability improvements (#6709)

On both the TOTP account and TOTP login screens perform the following:
* Make the "Device name" label optional if user registers the first
  TOTP credential. Make it mandatory otherwise,
* Denote the "Authenticator code" with asterisk, so it's clear it's
  required field (always),
* Add sentence to Step 3 of configuring TOTP credential explaining
  the user to provide device name label,

Also perform other CSS & locale / messages file changes, so the UX is
identical when creating OTP credentials on both of these pages

Add a corresponding testcase

Also address issues pointed out by mposolda's review. Thanks, Marek!

Signed-off-by: Jan Lieskovsky <jlieskov@redhat.com>
This commit is contained in:
Jan Lieskovsky
2020-02-03 19:34:28 +01:00
committed by GitHub
parent 154bce5693
commit b532570747
11 changed files with 321 additions and 43 deletions
@@ -38,6 +38,9 @@ import org.keycloak.services.validation.Validation;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.util.Collections;
import java.util.List;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
@@ -88,6 +91,17 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
return;
}
OTPCredentialProvider otpCredentialProvider = (OTPCredentialProvider) context.getSession().getProvider(CredentialProvider.class, "keycloak-otp");
final List<CredentialModel> otpCredentials = (otpCredentialProvider.isConfiguredFor(context.getRealm(), context.getUser()))
? context.getSession().userCredentialManager().getStoredCredentialsByType(context.getRealm(), context.getUser(), OTPCredentialModel.TYPE)
: Collections.EMPTY_LIST;
if (otpCredentials.size() >= 1 && Validation.isBlank(userLabel)) {
Response challenge = context.form()
.setAttribute("mode", mode)
.setError(Messages.MISSING_TOTP_DEVICE_NAME)
.createResponse(UserModel.RequiredAction.CONFIGURE_TOTP);
context.challenge(challenge);
return;
}
CredentialModel createdCredential = otpCredentialProvider.createCredential(context.getRealm(), context.getUser(), credentialModel);
UserCredentialModel credential = new UserCredentialModel(createdCredential.getId(), otpCredentialProvider.getType(), challengeResponse);
//If the type is HOTP, call verify once to consume the OTP used for registration and increase the counter.
@@ -16,6 +16,7 @@
*/
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;
@@ -25,6 +26,8 @@ import org.keycloak.models.utils.HmacOTP;
import org.keycloak.utils.TotpUtils;
import javax.ws.rs.core.UriBuilder;
import java.util.Collections;
import java.util.List;
/**
* Used for UpdateTotp required action
@@ -39,11 +42,17 @@ public class TotpBean {
private final String totpSecretQrCode;
private final boolean enabled;
private UriBuilder uriBuilder;
private final List<CredentialModel> otpCredentials;
public TotpBean(KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) {
this.realm = realm;
this.uriBuilder = uriBuilder;
this.enabled = session.userCredentialManager().isConfiguredFor(realm, user, OTPCredentialModel.TYPE);
if (enabled) {
otpCredentials = session.userCredentialManager().getStoredCredentialsByType(realm, user, OTPCredentialModel.TYPE);
} else {
otpCredentials = Collections.EMPTY_LIST;
}
this.totpSecret = HmacOTP.generateSecret(20);
this.totpSecretEncoded = TotpUtils.encode(totpSecret);
this.totpSecretQrCode = TotpUtils.qrCode(totpSecret, realm, user);
@@ -77,6 +86,8 @@ public class TotpBean {
return realm.getOTPPolicy();
}
public List<CredentialModel> getOtpCredentials() {
return otpCredentials;
}
}
@@ -58,6 +58,8 @@ public class Messages {
public static final String MISSING_TOTP = "missingTotpMessage";
public static final String MISSING_TOTP_DEVICE_NAME = "missingTotpDeviceNameMessage";
public static final String NOTMATCH_PASSWORD = "notMatchPasswordMessage";
public static final String INVALID_PASSWORD_EXISTING = "invalidPasswordExistingMessage";
@@ -569,8 +569,8 @@ public class KcinitTest extends AbstractTestRealmKeycloakTest {
exe.sendLine("password");
exe.waitForStderr("One Time Password:");
Pattern p = Pattern.compile("Open the application and enter the key\\s+(.+)\\s+Use the following configuration values");
//Pattern p = Pattern.compile("Open the application and enter the key");
Pattern p = Pattern.compile("Open the application and enter the key:\\s+(.+)\\s+Use the following configuration values");
//Pattern p = Pattern.compile("Open the application and enter the key:");
String stderr = exe.stderrString();
//System.out.println("***************");
@@ -24,10 +24,13 @@ import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil;
@@ -43,6 +46,7 @@ import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
import org.keycloak.testsuite.pages.PasswordPage;
import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils;
@@ -52,6 +56,7 @@ import org.keycloak.testsuite.util.UserBuilder;
import javax.mail.internet.MimeMessage;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.Assert.assertEquals;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
@@ -79,6 +84,9 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
@Page
protected PasswordPage passwordPage;
@Page
protected RegisterPage registerPage;
@Page
protected LoginPasswordResetPage resetPasswordPage;
@@ -380,4 +388,169 @@ public class ResetCredentialsAlternativeFlowsTest extends AbstractTestRealmKeycl
revertFlows();
}
}
// KEYCLOAK-12168 Verify the 'Device Name' label is optional for the first OTP credential created
// (either via Account page or by registering new user), but required for each next created OTP credential
@Test
public void deviceNameOptionalForFirstOTPCredentialButRequiredForEachNextOne() {
// Enable 'Default Action' on 'Configure OTP' RA for the 'test' realm
RequiredActionProviderRepresentation otpRequiredAction = testRealm().flows().getRequiredAction("CONFIGURE_TOTP");
otpRequiredAction.setDefaultAction(true);
testRealm().flows().updateRequiredAction("CONFIGURE_TOTP", otpRequiredAction);
try {
// Make a copy of the default Reset Credentials flow, but:
// * Without 'Send Reset Email' authenticator,
// * Without 'Reset Password' authenticator
final String newFlowAlias = "resetcred - KEYCLOAK-12168 - firstOTP - account - test";
configureResetCredentialsRemoveExecutionsAndBindTheFlow(
newFlowAlias,
Arrays.asList("reset-credential-email", "reset-password")
);
/* Verify the 'Device Name' is optional when creating new OTP credential via the Account page */
// Login & set up the initial OTP code for the user
loginPage.open();
loginPage.login("login@test.com", "password");
accountTotpPage.open();
Assert.assertTrue(accountTotpPage.isCurrent());
String pageSource = driver.getPageSource();
// Check the One-time code label is followed by asterisk character (since always required)
final String oneTimeCodeLabelFollowedByAsterisk = "(?s)<label for=\"totp\"((?!</span>).)+((?=<span class=\"required\">\\*).)*";
Assert.assertTrue(Pattern.compile(oneTimeCodeLabelFollowedByAsterisk).matcher(pageSource).find());
// Check the Device Name label is not followed by asterisk character (since optional if no OTP credential defined yet)
final String asteriskPrecededByDeviceNameLabel = "(?s)((?<=<label for=\"userLabel\").)+.*<span class=\"required\">\\s+\\*";
Assert.assertFalse(Pattern.compile(asteriskPrecededByDeviceNameLabel).matcher(pageSource).find());
// Create OTP credential with empty label
final String emptyOtpLabel = "";
accountTotpPage.configure(totp.generateTOTP(accountTotpPage.getTotpSecret()), emptyOtpLabel);
// Get the updated Account TOTP page source post OTP credential creation
pageSource = driver.getPageSource();
// Check if OTP credential with empty label was created successfully
final String emptyOtpLabelPresentInAuthenticatorTable = "(?s)<td class=\"provider\"/>";
Assert.assertTrue(Pattern.compile(emptyOtpLabelPresentInAuthenticatorTable).matcher(pageSource).find());
accountTotpPage.removeTotp();
// Logout
oauth.openLogout();
/* Verify the 'Device Name' is optional when creating the first OTP credential via the login config TOTP page */
// Register new user
loginPage.open();
loginPage.clickRegister();
registerPage.assertCurrent();
registerPage.register("Bruce", "Wilson", "bwilson@keycloak.org", "bwilson", "password", "password");
Assert.assertTrue(totpPage.isCurrent());
pageSource = driver.getPageSource();
// Check the One-time code label is required
Assert.assertTrue(Pattern.compile(oneTimeCodeLabelFollowedByAsterisk).matcher(pageSource).find());
// Check the Device Name label is optional
Assert.assertFalse(Pattern.compile(asteriskPrecededByDeviceNameLabel).matcher(pageSource).find());
// Create OTP credential with empty label
totpPage.configure(totp.generateTOTP(accountTotpPage.getTotpSecret()), emptyOtpLabel);
try {
Assert.assertTrue(totpPage.getError().isEmpty());
} catch (org.openqa.selenium.NoSuchElementException nsee) {
// OK to ignore if 'alert-error' element wasn't found
}
// Assert user authenticated
appPage.assertCurrent();
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
accountTotpPage.open();
Assert.assertTrue(accountTotpPage.isCurrent());
// Get the updated Account TOTP page source post OTP credential creation
pageSource = driver.getPageSource();
// Check if OTP credential with empty label was created successfully
Assert.assertTrue(Pattern.compile(emptyOtpLabelPresentInAuthenticatorTable).matcher(pageSource).find());
// Logout
oauth.openLogout();
/* Verify the 'Device Name' is required for each next OTP credential created via the login config TOTP page */
// Click "Forgot password" to define another OTP credential
loginPage.open();
loginPage.resetPassword();
// Should be on reset password page now. Provide email of previously registered user & click Submit button
Assert.assertTrue(resetPasswordPage.isCurrent());
resetPasswordPage.changePassword("bwilson@keycloak.org");
pageSource = driver.getPageSource();
// Check the One-time code label is required
Assert.assertTrue(Pattern.compile(oneTimeCodeLabelFollowedByAsterisk).matcher(pageSource).find());
// Check the Device Name label is required (since one OTP credential already defined)
final String deviceNameLabelFollowedByAsterisk = "(?s)<label for=\"userLabel\"((?!</span>).)+((?=<span class=\"required\">\\*).)*";
Assert.assertTrue(Pattern.compile(deviceNameLabelFollowedByAsterisk).matcher(pageSource).find());
// Try to create another OTP credential with empty label again. This
// should fail with error since OTP label is required in this case already
final String deviceNameLabelRequiredErrorMessage = "Please specify device name.";
totpPage.configure(totp.generateTOTP(accountTotpPage.getTotpSecret()), emptyOtpLabel);
Assert.assertTrue(totpPage.getError().equals(deviceNameLabelRequiredErrorMessage));
// Create 2nd OTP credential with valid (non-empty) Device Name label. This should pass
final String secondOtpLabel = "My 2nd OTP device";
totpPage.configure(totp.generateTOTP(accountTotpPage.getTotpSecret()), secondOtpLabel);
try {
Assert.assertTrue(totpPage.getError().isEmpty());
} catch (org.openqa.selenium.NoSuchElementException nsee) {
// OK to ignore if 'alert-error' element wasn't found
}
// Assert user authenticated
appPage.assertCurrent();
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
accountTotpPage.open();
Assert.assertTrue(accountTotpPage.isCurrent());
// Get the updated Account TOTP page source after both the OTP credentials were created
pageSource = driver.getPageSource();
// Verify 2nd OTP credential was successfully created too
Assert.assertTrue(pageSource.contains(secondOtpLabel));
// Remove both OTP credentials
accountTotpPage.removeTotp();
accountTotpPage.removeTotp();
// Logout
oauth.openLogout();
// Undo setup changes performed within the test
} finally {
revertFlows();
// Disable 'Default Action' on 'Configure OTP' RA for the 'test' realm
otpRequiredAction.setDefaultAction(false);
testRealm().flows().updateRequiredAction("CONFIGURE_TOTP", otpRequiredAction);
// Remove the within test registered 'bwilson' user
testingClient.server("test").run(session -> {
UserManager um = new UserManager(session);
UserModel user = session.users().getUserByUsername("bwilson", session.getContext().getRealm());
if (user != null) {
um.removeUser(session.getContext().getRealm(), user);
}
});
}
}
}
@@ -130,12 +130,13 @@ revoke=Revoke Grant
configureAuthenticators=Configured Authenticators
mobile=Mobile
totpStep1=Install one of the following applications on your mobile
totpStep2=Open the application and scan the barcode
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.
totpStep3DeviceName=Provide a Device Name to help you manage your OTP devices.
totpManualStep2=Open the application and enter the key
totpManualStep3=Use the following configuration values if the application allows setting them
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?
@@ -147,6 +148,7 @@ totpAlgorithm=Algorithm
totpDigits=Digits
totpInterval=Interval
totpCounter=Counter
totpDeviceName=Device Name
missingUsernameMessage=Please specify username.
missingFirstNameMessage=Please specify first name.
@@ -158,6 +160,7 @@ notMatchPasswordMessage=Passwords don''t match.
invalidUserMessage=Invalid user
missingTotpMessage=Please specify authenticator code.
missingTotpDeviceNameMessage=Please specify device name.
invalidPasswordExistingMessage=Invalid existing password.
invalidPasswordConfirmMessage=Password confirmation doesn''t match.
invalidTotpMessage=Invalid authenticator code.
@@ -300,7 +303,7 @@ authenticatorBackupCodesFinishSetUpMessage=12 backup codes were generated at thi
authenticatorMobileSetupTitle=Mobile Authenticator Setup
smscodeIntroMessage=Enter your phone number and a verification code will be sent to your phone.
mobileSetupStep1=Install an authenticator application on your phone. The applications listed here are supported.
mobileSetupStep2=Open the application and scan the barcode.
mobileSetupStep2=Open the application and scan the barcode:
mobileSetupStep3=Enter the one-time code provided by the application and click Save to finish the setup.
scanBarCode=Want to scan the barcode?
enterBarCode=Enter the one-time code
@@ -1,7 +1,17 @@
<#import "template.ftl" as layout>
<@layout.mainLayout active='totp' bodyClass='totp'; section>
<h2>${msg("authenticatorTitle")}</h2>
<div class="row">
<div class="col-md-10">
<h2>${msg("authenticatorTitle")}</h2>
</div>
<#if totp.otpCredentials?size == 0>
<div class="col-md-2 subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
</div>
</#if>
</div>
<#if totp.enabled>
<table class="table table-bordered table-striped">
<thead>
@@ -80,6 +90,7 @@
</#if>
<li>
<p>${msg("totpStep3")}</p>
<p>${msg("totpStep3DeviceName")}</p>
</li>
</ol>
@@ -89,7 +100,7 @@
<input type="hidden" id="stateChecker" name="stateChecker" value="${stateChecker}">
<div class="form-group">
<div class="col-sm-2 col-md-2">
<label for="totp" class="control-label">${msg("authenticatorCode")}</label>
<label for="totp" class="control-label">${msg("authenticatorCode")}</label> <span class="required">*</span>
</div>
<div class="col-sm-10 col-md-10">
@@ -100,9 +111,9 @@
</div>
<div class="form-group">
<div class="form-group" ${messagesPerField.printIfExists('userLabel',properties.kcFormGroupErrorClass!)}">
<div class="col-sm-2 col-md-2">
<label for="userLabel" class="control-label">Device Name</label>
<label for="userLabel" class="control-label">${msg("totpDeviceName")}</label> <#if totp.otpCredentials?size gte 1><span class="required">*</span></#if>
</div>
<div class="col-sm-10 col-md-10">
@@ -115,10 +126,12 @@
<div class="">
<button type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
name="submitAction" value="Save">${msg("doSave")}</button>
id="saveTOTPBtn" name="submitAction" value="Save">${msg("doSave")}
</button>
<button type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}"
name="submitAction" value="Cancel">${msg("doCancel")}</button>
id="cancelTOTPBtn" name="submitAction" value="Cancel">${msg("doCancel")}
</button>
</div>
</div>
</div>
@@ -1,9 +1,10 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=true; section>
<@layout.registrationLayout displayInfo=true displayRequiredFields=true; section>
<#if section = "header">
${msg("loginTotpTitle")}
<#elseif section = "form">
<#elseif section = "form">
<ol id="kc-totp-settings">
<li>
@@ -46,11 +47,15 @@
</#if>
<li>
<p>${msg("loginTotpStep3")}</p>
<p>${msg("loginTotpStep3DeviceName")}</p>
</li>
</ol>
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-totp-settings-form" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcInputWrapperClass!}">
<label for="totp" class="control-label">${msg("authenticatorCode")}</label> <span class="required">*</span>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="totp" name="totp" autocomplete="off" class="${properties.kcInputClass!}" />
</div>
@@ -58,9 +63,9 @@
<#if mode??><input type="hidden" id="mode" name="mode" value="${mode}"/></#if>
</div>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcFormGroupClass!}" ${messagesPerField.printIfExists('userLabel',properties.kcFormGroupErrorClass!)}">
<div class="${properties.kcInputWrapperClass!}">
<label for="userLabel" class="control-label">Device Name</label>
<label for="userLabel" class="control-label">${msg("loginTotpDeviceName")}</label> <#if totp.otpCredentials?size gte 1><span class="required">*</span></#if>
</div>
<div class="${properties.kcInputWrapperClass!}">
@@ -69,10 +74,19 @@
</div>
<#if isAppInitiatedAction??>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
<button class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!} ${properties.kcButtonLargeClass!}" type="submit" name="cancel-aia" value="true" />${msg("doCancel")}</button>
<input type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
id="saveTOTPBtn" value="${msg("doSubmit")}"
/>
<button type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!} ${properties.kcButtonLargeClass!}"
id="cancelTOTPBtn" name="cancel-aia" value="true" />${msg("doCancel")}
</button>
<#else>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
<input type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
id="saveTOTPBtn" value="${msg("doSubmit")}"
/>
</#if>
</form>
</#if>
@@ -86,11 +86,12 @@ rolesScopeConsentText=User roles
restartLoginTooltip=Restart login
loginTotpIntro=You need to set up a One Time Password generator to access this account
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
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.
loginTotpStep3DeviceName=Provide a Device Name to help you manage your OTP devices.
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?
loginCredential=Credential
@@ -100,6 +101,7 @@ loginTotpAlgorithm=Algorithm
loginTotpDigits=Digits
loginTotpInterval=Interval
loginTotpCounter=Counter
loginTotpDeviceName=Device Name
loginTotp.totp=Time-based
loginTotp.hotp=Counter-based
@@ -159,6 +161,8 @@ client_admin-cli=Admin CLI
client_realm-management=Realm Management
client_broker=Broker
requiredFields=Required fields
invalidUserMessage=Invalid username or password.
invalidUsernameMessage=Invalid username.
invalidUsernameOrEmailMessage=Invalid username or email.
@@ -177,6 +181,7 @@ missingEmailMessage=Please specify email.
missingUsernameMessage=Please specify username.
missingPasswordMessage=Please specify password.
missingTotpMessage=Please specify authenticator code.
missingTotpDeviceNameMessage=Please specify device name.
notMatchPasswordMessage=Passwords don''t match.
invalidPasswordExistingMessage=Invalid existing password.
@@ -1,4 +1,4 @@
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayWide=false>
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false displayWide=false>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" class="${properties.kcHtmlClass!}">
@@ -52,22 +52,55 @@
</div>
</div>
</#if>
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<h1 id="kc-page-title"><#nested "header"></h1>
<#else>
<#nested "show-username">
<div class="${properties.kcFormGroupClass!}">
<div id="kc-username">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</#if>
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#if displayRequiredFields>
<div class="${properties.kcContentWrapperClass!}">
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
</div>
<div class="col-md-10">
<h1 id="kc-page-title"><#nested "header"></h1>
</div>
</div>
<#else>
<h1 id="kc-page-title"><#nested "header"></h1>
</#if>
<#else>
<#if displayRequiredFields>
<div class="${properties.kcContentWrapperClass!}">
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
</div>
<div class="col-md-10">
<#nested "show-username">
<div class="${properties.kcFormGroupClass!}">
<div id="kc-username">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</div>
</div>
<#else>
<#nested "show-username">
<div class="${properties.kcFormGroupClass!}">
<div id="kc-username">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</#if>
</#if>
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
@@ -159,6 +159,16 @@ div.kc-logo-text span {
/* TOTP */
.subtitle {
text-align: right;
margin-top: 30px;
color: #909090;
}
.required {
color: #CB2915;
}
ol#kc-totp-settings {
margin: 0;
padding-left: 20px;