[KEYCLOAK-2052] Allows independently set timeouts for e-mail verification link and rest e.g. forgot password link

Co-authored-by: Hynek Mlnarik <hmlnarik@redhat.com>
This commit is contained in:
Bruno Oliveira 2017-10-10 14:04:16 +02:00
parent 925d5e1dea
commit 03d0488335
20 changed files with 695 additions and 59 deletions

View file

@ -454,6 +454,12 @@ public class RealmAdapter implements CachedRealmModel {
updated.setAccessCodeLifespanUserAction(seconds);
}
@Override
public Map<String, Integer> getUserActionTokenLifespans() {
if (isUpdated()) return updated.getUserActionTokenLifespans();
return cached.getUserActionTokenLifespans();
}
@Override
public int getAccessCodeLifespanLogin() {
if (isUpdated()) return updated.getAccessCodeLifespanLogin();
@ -490,6 +496,20 @@ public class RealmAdapter implements CachedRealmModel {
updated.setActionTokenGeneratedByUserLifespan(seconds);
}
@Override
public int getActionTokenGeneratedByUserLifespan(String actionTokenId) {
if (isUpdated()) return updated.getActionTokenGeneratedByUserLifespan(actionTokenId);
return cached.getActionTokenGeneratedByUserLifespan(actionTokenId);
}
@Override
public void setActionTokenGeneratedByUserLifespan(String actionTokenId, Integer seconds) {
if (seconds != null) {
getDelegateForUpdate();
updated.setActionTokenGeneratedByUserLifespan(actionTokenId, seconds);
}
}
@Override
public List<RequiredCredentialModel> getRequiredCredentials() {
if (isUpdated()) return updated.getRequiredCredentials();

View file

@ -143,6 +143,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected Map<String, String> attributes;
private Map<String, Integer> userActionTokenLifespans;
public CachedRealm(Long revision, RealmModel model) {
super(revision, model.getId());
name = model.getName();
@ -192,6 +194,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
emailTheme = model.getEmailTheme();
requiredCredentials = model.getRequiredCredentials();
userActionTokenLifespans = Collections.unmodifiableMap(new HashMap<>(model.getUserActionTokenLifespans()));
this.identityProviders = new ArrayList<>();
@ -407,6 +410,11 @@ public class CachedRealm extends AbstractExtendableRevisioned {
public int getAccessCodeLifespanUserAction() {
return accessCodeLifespanUserAction;
}
public Map<String, Integer> getUserActionTokenLifespans() {
return userActionTokenLifespans;
}
public int getAccessCodeLifespanLogin() {
return accessCodeLifespanLogin;
}
@ -419,6 +427,18 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return actionTokenGeneratedByUserLifespan;
}
/**
* This method is supposed to return user lifespan based on the action token ID
* provided. If nothing is provided, it will return the default lifespan.
* @param actionTokenId
* @return lifespan
*/
public int getActionTokenGeneratedByUserLifespan(String actionTokenId) {
if (actionTokenId == null || this.userActionTokenLifespans.get(actionTokenId) == null)
return getActionTokenGeneratedByUserLifespan();
return this.userActionTokenLifespans.get(actionTokenId);
}
public List<RequiredCredentialModel> getRequiredCredentials() {
return requiredCredentials;
}
@ -609,5 +629,4 @@ public class CachedRealm extends AbstractExtendableRevisioned {
public Map<String, String> getAttributes() {
return attributes;
}
}

View file

@ -473,6 +473,19 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
em.flush();
}
@Override
public Map<String, Integer> getUserActionTokenLifespans() {
Map<String, Integer> userActionTokens = new HashMap<>();
getAttributes().entrySet().stream()
.filter(Objects::nonNull)
.filter(entry -> entry.getKey().startsWith(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "."))
.forEach(entry -> userActionTokens.put(entry.getKey().substring(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN.length() + 1), Integer.valueOf(entry.getValue())));
return Collections.unmodifiableMap(userActionTokens);
}
@Override
public int getAccessCodeLifespanLogin() {
return realm.getAccessCodeLifespanLogin();
@ -504,6 +517,17 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
setAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN, actionTokenGeneratedByUserLifespan);
}
@Override
public int getActionTokenGeneratedByUserLifespan(String actionTokenId) {
return getAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "." + actionTokenId, getAccessCodeLifespanUserAction());
}
@Override
public void setActionTokenGeneratedByUserLifespan(String actionTokenId, Integer actionTokenGeneratedByUserLifespan) {
if (actionTokenGeneratedByUserLifespan != null)
setAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "." + actionTokenId, actionTokenGeneratedByUserLifespan);
}
protected RequiredCredentialModel initRequiredCredentialModel(String type) {
RequiredCredentialModel model = RequiredCredentialModel.BUILT_IN.get(type);
if (model == null) {
@ -647,7 +671,7 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
entities.remove(entity);
}
em.flush();
}
}
@Override
public List<GroupModel> getDefaultGroups() {
@ -1954,4 +1978,5 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
if (c == null) return null;
return entityToModel(c);
}
}

View file

@ -186,6 +186,13 @@ public interface RealmModel extends RoleContainerModel {
void setAccessCodeLifespanUserAction(int seconds);
/**
* This method will return a map with all the lifespans available
* or an empty map, but never null.
* @return map with user action token lifespans
*/
Map<String, Integer> getUserActionTokenLifespans();
int getAccessCodeLifespanLogin();
void setAccessCodeLifespanLogin(int seconds);
@ -196,6 +203,9 @@ public interface RealmModel extends RoleContainerModel {
int getActionTokenGeneratedByUserLifespan();
void setActionTokenGeneratedByUserLifespan(int seconds);
int getActionTokenGeneratedByUserLifespan(String actionTokenType);
void setActionTokenGeneratedByUserLifespan(String actionTokenType, Integer seconds);
List<RequiredCredentialModel> getRequiredCredentials();
void addRequiredCredential(String cred);

View file

@ -116,7 +116,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator
UriInfo uriInfo = session.getContext().getUri();
AuthenticationSessionModel authSession = context.getAuthenticationSession();
int validityInSecs = realm.getActionTokenGeneratedByUserLifespan();
int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(IdpVerifyAccountLinkActionToken.TOKEN_TYPE);
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
EventBuilder event = context.getEvent().clone().event(EventType.SEND_IDENTITY_PROVIDER_LINK)

View file

@ -85,7 +85,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
return;
}
int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan();
int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan(ResetCredentialsActionToken.TOKEN_TYPE);
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
// We send the secret in the email in a link as a query param.

View file

@ -131,7 +131,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
RealmModel realm = session.getContext().getRealm();
UriInfo uriInfo = session.getContext().getUri();
int validityInSecs = realm.getActionTokenGeneratedByUserLifespan();
int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(VerifyEmailActionToken.TOKEN_TYPE);
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), absoluteExpirationInSecs, authSession.getId(), user.getEmail());

View file

@ -56,7 +56,11 @@ public class MailUtils {
assertEquals("text/html; charset=UTF-8", htmlContentType);
final String htmlBody = (String) multipart.getBodyPart(1).getContent();
final String htmlChangePwdUrl = getLink(htmlBody);
// .replace() accounts for escaping the ampersand
// It's not escaped in the html version because html retrieved from a
// message bundle is considered safe and it must be unescaped to display
// properly.
final String htmlChangePwdUrl = MailUtils.getLink(htmlBody).replace("&", "&amp;");
assertEquals(htmlChangePwdUrl, textChangePwdUrl);

View file

@ -27,14 +27,12 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.auth.page.AuthRealm;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.ProceedPage;
@ -45,16 +43,19 @@ import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.pages.VerifyEmailPage;
import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.UserActionTokenBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.internet.MimeMessage;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.hamcrest.Matchers;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -448,6 +449,95 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
}
}
@Test
public void verifyEmailExpiredCodedPerActionLifespan() throws IOException, MessagingException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
realmRep.setAttributes(UserActionTokenBuilder.create().verifyEmailLifespan(60).build());
testRealm().update(realmRep);
loginPage.open();
loginPage.login("test-user@localhost", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message);
events.poll();
try {
setTimeOffset(70);
driver.navigate().to(verificationUrl.trim());
loginPage.assertCurrent();
assertEquals("Action expired. Please start again.", loginPage.getError());
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR)
.error(Errors.EXPIRED_CODE)
.client((String)null)
.user(testUserId)
.session((String)null)
.clearDetails()
.detail(Details.ACTION, VerifyEmailActionToken.TOKEN_TYPE)
.assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
@Test
public void verifyEmailExpiredCodedPerActionMultipleTimeouts() throws IOException, MessagingException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
//Make sure that one attribute settings won't affect the other
realmRep.setAttributes(UserActionTokenBuilder.create().verifyEmailLifespan(60).resetCredentialsLifespan(300).build());
testRealm().update(realmRep);
loginPage.open();
loginPage.login("test-user@localhost", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message);
events.poll();
try {
setTimeOffset(70);
driver.navigate().to(verificationUrl.trim());
loginPage.assertCurrent();
assertEquals("Action expired. Please start again.", loginPage.getError());
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR)
.error(Errors.EXPIRED_CODE)
.client((String)null)
.user(testUserId)
.session((String)null)
.clearDetails()
.detail(Details.ACTION, VerifyEmailActionToken.TOKEN_TYPE)
.assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
@Test
public void verifyEmailExpiredCodeAndExpiredSession() throws IOException, MessagingException {
loginPage.open();

View file

@ -47,6 +47,7 @@ import org.keycloak.testsuite.pages.OAuthGrantPage;
import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.pages.VerifyEmailPage;
import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.UserBuilder;
@ -337,7 +338,7 @@ public class BrowserButtonsTest extends AbstractTestRealmKeycloakTest {
// Receive email
MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1];
String changePasswordUrl = ResetPasswordTest.getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
driver.navigate().to(changePasswordUrl.trim());

View file

@ -38,12 +38,13 @@ import org.keycloak.testsuite.pages.VerifyEmailPage;
import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.UserActionTokenBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.internet.MimeMessage;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@ -131,7 +132,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
driver.navigate().to(changePasswordUrl.trim());
@ -251,7 +252,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
driver.navigate().to(changePasswordUrl.trim());
@ -295,7 +296,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
driver.navigate().to(changePasswordUrl.trim());
@ -362,7 +363,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
try {
setTimeOffset(1800 + 23);
@ -399,7 +400,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
setTimeOffset(70);
@ -418,6 +419,84 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void resetPasswordExpiredCodeShortPerActionLifespan() throws IOException, MessagingException, InterruptedException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).build());
testRealm().update(realmRep);
try {
initiateResetPasswordFromResetPasswordPage("login-test");
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
setTimeOffset(70);
driver.navigate().to(changePasswordUrl.trim());
loginPage.assertCurrent();
assertEquals("Action expired. Please start again.", loginPage.getError());
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
@Test
public void resetPasswordExpiredCodeShortPerActionMultipleTimeouts() throws IOException, MessagingException, InterruptedException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
//Make sure that one attribute settings won't affect the other
realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).verifyEmailLifespan(300).build());
testRealm().update(realmRep);
try {
initiateResetPasswordFromResetPasswordPage("login-test");
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
setTimeOffset(70);
driver.navigate().to(changePasswordUrl.trim());
loginPage.assertCurrent();
assertEquals("Action expired. Please start again.", loginPage.getError());
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
// KEYCLOAK-4016
@Test
public void resetPasswordExpiredCodeAndAuthSession() throws IOException, MessagingException, InterruptedException {
@ -439,7 +518,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message).replace("&amp;", "&");
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&amp;", "&");
setTimeOffset(70);
@ -463,6 +542,92 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void resetPasswordExpiredCodeAndAuthSessionPerActionLifespan() throws IOException, MessagingException, InterruptedException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).build());
testRealm().update(realmRep);
try {
initiateResetPasswordFromResetPasswordPage("login-test");
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&amp;", "&");
setTimeOffset(70);
log.debug("Going to reset password URI.");
driver.navigate().to(oauth.AUTH_SERVER_ROOT + "/realms/test/login-actions/reset-credentials"); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
log.debug("Removing cookies.");
driver.manage().deleteAllCookies();
driver.navigate().to(changePasswordUrl.trim());
errorPage.assertCurrent();
Assert.assertEquals("Action expired.", errorPage.getError());
String backToAppLink = errorPage.getBackToApplicationLink();
Assert.assertTrue(backToAppLink.endsWith("/app/auth"));
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
@Test
public void resetPasswordExpiredCodeAndAuthSessionPerActionMultipleTimeouts() throws IOException, MessagingException, InterruptedException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
//Make sure that one attribute settings won't affect the other
realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).verifyEmailLifespan(300).build());
testRealm().update(realmRep);
try {
initiateResetPasswordFromResetPasswordPage("login-test");
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&amp;", "&");
setTimeOffset(70);
log.debug("Going to reset password URI.");
driver.navigate().to(oauth.AUTH_SERVER_ROOT + "/realms/test/login-actions/reset-credentials"); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
log.debug("Removing cookies.");
driver.manage().deleteAllCookies();
driver.navigate().to(changePasswordUrl.trim());
errorPage.assertCurrent();
Assert.assertEquals("Action expired.", errorPage.getError());
String backToAppLink = errorPage.getBackToApplicationLink();
Assert.assertTrue(backToAppLink.endsWith("/app/auth"));
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
// KEYCLOAK-5061
@Test
@ -495,7 +660,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
setTimeOffset(70);
@ -514,6 +679,103 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void resetPasswordExpiredCodeForgotPasswordFlowPerActionLifespan() throws IOException, MessagingException, InterruptedException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).build());
testRealm().update(realmRep);
try {
// Redirect directly to KC "forgot password" endpoint instead of "authenticate" endpoint
String loginUrl = oauth.getLoginFormUrl();
String forgotPasswordUrl = loginUrl.replace("/auth?", "/forgot-credentials?"); // Workaround, but works
driver.navigate().to(forgotPasswordUrl);
resetPasswordPage.assertCurrent();
resetPasswordPage.changePassword("login-test");
loginPage.assertCurrent();
assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage());
expectedMessagesCount++;
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
setTimeOffset(70);
driver.navigate().to(changePasswordUrl.trim());
resetPasswordPage.assertCurrent();
assertEquals("Action expired. Please start again.", loginPage.getError());
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
@Test
public void resetPasswordExpiredCodeForgotPasswordFlowPerActionMultipleTimeouts() throws IOException, MessagingException, InterruptedException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
//Make sure that one attribute settings won't affect the other
realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).verifyEmailLifespan(300).build());
testRealm().update(realmRep);
try {
// Redirect directly to KC "forgot password" endpoint instead of "authenticate" endpoint
String loginUrl = oauth.getLoginFormUrl();
String forgotPasswordUrl = loginUrl.replace("/auth?", "/forgot-credentials?"); // Workaround, but works
driver.navigate().to(forgotPasswordUrl);
resetPasswordPage.assertCurrent();
resetPasswordPage.changePassword("login-test");
loginPage.assertCurrent();
assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage());
expectedMessagesCount++;
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
setTimeOffset(70);
driver.navigate().to(changePasswordUrl.trim());
resetPasswordPage.assertCurrent();
assertEquals("Action expired. Please start again.", loginPage.getError());
events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
} finally {
setTimeOffset(0);
realmRep.setAttributes(originalAttributes);
testRealm().update(realmRep);
}
}
@Test
public void resetPasswordDisabledUser() throws IOException, MessagingException, InterruptedException {
UserRepresentation user = findUser("login-test");
@ -608,7 +870,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).session((String)null).user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
@ -701,7 +963,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
MimeMessage message = greenMail.getReceivedMessages()[0];
String changePasswordUrl = getPasswordResetEmailLink(message);
String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
log.debug("Going to reset password URI.");
driver.navigate().to(resetUri); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
@ -710,8 +972,6 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
log.debug("Going to URI from e-mail.");
driver.navigate().to(changePasswordUrl.trim());
// System.out.println(driver.getPageSource());
updatePasswordPage.assertCurrent();
updatePasswordPage.changePassword("resetPassword", "resetPassword");
@ -719,32 +979,4 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
infoPage.assertCurrent();
assertEquals("Your account has been updated.", infoPage.getInfo());
}
public static String getPasswordResetEmailLink(MimeMessage message) throws IOException, MessagingException {
Multipart multipart = (Multipart) message.getContent();
final String textContentType = multipart.getBodyPart(0).getContentType();
assertEquals("text/plain; charset=UTF-8", textContentType);
final String textBody = (String) multipart.getBodyPart(0).getContent();
final String textChangePwdUrl = MailUtils.getLink(textBody);
final String htmlContentType = multipart.getBodyPart(1).getContentType();
assertEquals("text/html; charset=UTF-8", htmlContentType);
final String htmlBody = (String) multipart.getBodyPart(1).getContent();
// .replace() accounts for escaping the ampersand
// It's not escaped in the html version because html retrieved from a
// message bundle is considered safe and it must be unescaped to display
// properly.
final String htmlChangePwdUrl = MailUtils.getLink(htmlBody).replace("&", "&amp;");
assertEquals(htmlChangePwdUrl, textChangePwdUrl);
return htmlChangePwdUrl;
}
}

View file

@ -39,7 +39,6 @@ import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.util.JsonSerialization;
import javax.ws.rs.core.Response;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.reflect.Method;
@ -251,7 +250,7 @@ public class AssertAdminEvents implements TestRule {
// Reflection-based comparing for other types - compare the non-null fields of "expected" representation with the "actual" representation from the event
for (Method method : Reflections.getAllDeclaredMethods(expectedRep.getClass())) {
if (method.getName().startsWith("get") || method.getName().startsWith("is")) {
if (method.getParameterCount() == 0 && (method.getName().startsWith("get") || method.getName().startsWith("is"))) {
Object expectedValue = Reflections.invokeMethod(method, expectedRep);
if (expectedValue != null) {
Object actualValue = Reflections.invokeMethod(method, actualRep);

View file

@ -0,0 +1,38 @@
package org.keycloak.testsuite.util;
import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken;
import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
import java.util.HashMap;
import java.util.Map;
/**
* @author <a href="mailto:bruno@abstractj.org">Bruno Oliveira</a>
*/
public class UserActionTokenBuilder {
private final Map<String, String> realmAttributes;
private static final String ATTR_PREFIX = "actionTokenGeneratedByUserLifespan.";
private UserActionTokenBuilder(HashMap<String, String> attr) {
realmAttributes = attr;
}
public static UserActionTokenBuilder create() {
return new UserActionTokenBuilder(new HashMap<>());
}
public UserActionTokenBuilder resetCredentialsLifespan(int lifespan) {
realmAttributes.put(ATTR_PREFIX + ResetCredentialsActionToken.TOKEN_TYPE, String.valueOf(lifespan));
return this;
}
public UserActionTokenBuilder verifyEmailLifespan(int lifespan) {
realmAttributes.put(ATTR_PREFIX + VerifyEmailActionToken.TOKEN_TYPE, String.valueOf(lifespan));
return this;
}
public Map<String, String> build() {
return realmAttributes;
}
}

View file

@ -61,6 +61,18 @@ public class TokenSettings extends RealmSettings {
@FindBy(name = "ssoSessionMaxLifespanUnit")
private Select sessionLifespanTimeoutUnit;
@FindBy(name = "actionTokenAttributeSelect")
private Select actionTokenAttributeSelect;
@FindBy(name = "actionTokenAttributeUnit")
private Select actionTokenAttributeUnit;
@FindBy(id = "actionTokenAttributeTime")
private WebElement actionTokenAttributeTime;
@FindBy(xpath = "//button[@data-ng-click='resetToDefaultToken(actionTokenId)']")
private WebElement resetButton;
public void setSessionTimeout(int timeout, TimeUnit unit) {
setTimeout(sessionTimeoutUnit, sessionTimeout, timeout, unit);
}
@ -69,6 +81,12 @@ public class TokenSettings extends RealmSettings {
setTimeout(sessionLifespanTimeoutUnit, sessionLifespanTimeout, time, unit);
}
public void setOperation(String tokenType, int time, TimeUnit unit) {
waitUntilElement(sessionTimeout).is().present();
actionTokenAttributeSelect.selectByValue(tokenType.toLowerCase());
setTimeout(actionTokenAttributeUnit, actionTokenAttributeTime, time, unit);
}
private void setTimeout(Select timeoutElement, WebElement unitElement,
int timeout, TimeUnit unit) {
waitUntilElement(sessionTimeout).is().present();
@ -77,5 +95,25 @@ public class TokenSettings extends RealmSettings {
unitElement.sendKeys(valueOf(timeout));
}
public boolean isOperationEquals(String tokenType, int timeout, TimeUnit unit) {
selectOperation(tokenType);
waitUntilElement(sessionTimeout).is().present();
actionTokenAttributeSelect.selectByValue(tokenType.toLowerCase());
return actionTokenAttributeTime.getAttribute("value").equals(Integer.toString(timeout)) &&
actionTokenAttributeUnit.getFirstSelectedOption().getText().equals(capitalize(unit.name().toLowerCase()));
}
public void resetActionToken(String tokenType) {
selectOperation(tokenType);
waitUntilElement(resetButton).is().visible();
resetButton.click();
}
public void selectOperation(String tokenType) {
waitUntilElement(sessionTimeout).is().present();
actionTokenAttributeSelect.selectByValue(tokenType.toLowerCase());
}
}
}

View file

@ -71,8 +71,6 @@ public abstract class AbstractConsoleTest extends AbstractAuthTest {
if (!testContext.isAdminLoggedIn()) {
loginToMasterRealmAdminConsoleAs(adminUser);
testContext.setAdminLoggedIn(true);
} else {
// adminConsoleRealmPage.navigateTo();
}
}

View file

@ -17,13 +17,27 @@
*/
package org.keycloak.testsuite.console.realm;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken;
import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
import org.keycloak.models.jpa.entities.RealmAttributes;
import org.keycloak.testsuite.auth.page.account.Account;
import org.keycloak.testsuite.console.page.realm.TokenSettings;
import org.keycloak.testsuite.console.page.users.UserAttributes;
import org.keycloak.testsuite.pages.VerifyEmailPage;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWithLoginUrlOf;
@ -36,13 +50,20 @@ public class TokensTest extends AbstractRealmTest {
@Page
private TokenSettings tokenSettingsPage;
@Page
private UserAttributes userAttributesPage;
@Page
protected VerifyEmailPage verifyEmailPage;
@Page
private Account testRealmAccountPage;
private static final int TIMEOUT = 1;
private static final TimeUnit TIME_UNIT = TimeUnit.MINUTES;
@Before
public void beforeTokensTest() {
// configure().realmSettings();
// tabs().tokens();
tokenSettingsPage.navigateTo();
}
@ -78,10 +99,92 @@ public class TokensTest extends AbstractRealmTest {
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage); // assert logged out (lifespan exceeded)
}
@Test
public void testLifespanOfVerifyEmailActionTokenPropagated() throws InterruptedException {
tokenSettingsPage.form().setOperation(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS);
tokenSettingsPage.form().save();
assertAlertSuccess();
loginToTestRealmConsoleAs(testUser);
driver.navigate().refresh();
tokenSettingsPage.navigateTo();
tokenSettingsPage.form().selectOperation(VerifyEmailActionToken.TOKEN_TYPE);
assertTrue("User action token for verify e-mail expected",
tokenSettingsPage.form().isOperationEquals(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS));
}
@Test
public void testLifespanActionTokenPropagatedForVerifyEmailAndResetPassword() throws InterruptedException {
tokenSettingsPage.form().setOperation(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS);
tokenSettingsPage.form().setOperation(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS);
tokenSettingsPage.form().save();
assertAlertSuccess();
loginToTestRealmConsoleAs(testUser);
driver.navigate().refresh();
tokenSettingsPage.navigateTo();
assertTrue("User action token for verify e-mail expected",
tokenSettingsPage.form().isOperationEquals(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS));
assertTrue("User action token for reset credentials expected",
tokenSettingsPage.form().isOperationEquals(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS));
//Verify if values were properly propagated
Map<String, Integer> userActionTokens = getUserActionTokens();
assertThat("Action Token attributes list should contain 2 items", userActionTokens.entrySet(), Matchers.hasSize(2));
assertThat(userActionTokens, Matchers.hasEntry(VerifyEmailActionToken.TOKEN_TYPE, Long.toString(TimeUnit.DAYS.toSeconds(TIMEOUT))));
assertThat(userActionTokens, Matchers.hasEntry(ResetCredentialsActionToken.TOKEN_TYPE, Long.toString(TimeUnit.HOURS.toSeconds(TIMEOUT))));
}
@Test
public void testLifespanActionTokenResetForVerifyEmail() throws InterruptedException {
tokenSettingsPage.form().setOperation(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS);
tokenSettingsPage.form().setOperation(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS);
tokenSettingsPage.form().save();
assertAlertSuccess();
loginToTestRealmConsoleAs(testUser);
driver.navigate().refresh();
tokenSettingsPage.navigateTo();
assertTrue("User action token for verify e-mail expected",
tokenSettingsPage.form().isOperationEquals(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS));
assertTrue("User action token for reset credentials expected",
tokenSettingsPage.form().isOperationEquals(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS));
//Remove VerifyEmailActionToken and reset attribute
tokenSettingsPage.form().resetActionToken(VerifyEmailActionToken.TOKEN_TYPE);
tokenSettingsPage.form().save();
//Verify if values were properly propagated
Map<String, Integer> userActionTokens = getUserActionTokens();
assertTrue("Action Token attributes list should contain 1 item", userActionTokens.size() == 1);
assertNull("VerifyEmailActionToken should not exist", userActionTokens.get(VerifyEmailActionToken.TOKEN_TYPE));
assertEquals("ResetCredentialsActionToken expected to be propagated",
userActionTokens.get(ResetCredentialsActionToken.TOKEN_TYPE).longValue(), TimeUnit.HOURS.toSeconds(TIMEOUT));
}
private Map<String, Integer> getUserActionTokens() {
Map<String, Integer> userActionTokens = new HashMap<>();
adminClient.realm(testRealmPage.getAuthRealm()).toRepresentation().getAttributes().entrySet().stream()
.filter(Objects::nonNull)
.filter(entry -> entry.getKey().startsWith(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "."))
.forEach(entry -> userActionTokens.put(entry.getKey().substring(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN.length() + 1), Integer.valueOf(entry.getValue())));
return userActionTokens;
}
private void waitForTimeout (int timeout) throws InterruptedException {
log.info("Wait for timeout: " + timeout + " " + TIME_UNIT);
TIME_UNIT.sleep(timeout);
log.info("Timeout reached");
}
}

View file

@ -114,6 +114,15 @@ action-token-generated-by-admin-lifespan=Default Admin-Initiated Action Lifespan
action-token-generated-by-admin-lifespan.tooltip=Maximum time before an action permit sent to a user by admin is expired. This value is recommended to be long to allow admins send e-mails for users that are currently offline. The default timeout can be overridden right before issuing the token.
action-token-generated-by-user-lifespan=User-Initiated Action Lifespan
action-token-generated-by-user-lifespan.tooltip=Maximum time before an action permit sent by a user (e.g. forgot password e-mail) is expired. This value is recommended to be short because it is expected that the user would react to self-created action quickly.
action-token-generated-by-user.execute-actions=Execute Actions
action-token-generated-by-user.idp-verify-account-via-email=IdP Account E-mail Verification
action-token-generated-by-user.reset-credentials=Forgot Password
action-token-generated-by-user.verify-email=E-mail Verification
action-token-generated-by-user.tooltip=Override default settings of maximum time before an action permit sent by a user (e.g. forgot password e-mail) is expired for specific action. This value is recommended to be short because it is expected that the user would react to self-created action quickly.
action-token-generated-by-user.reset=Reset
action-token-generated-by-user.operation=Override User-Initiated Action Lifespan
client-login-timeout=Client login timeout
client-login-timeout.tooltip=Max time an client has to finish the access token protocol. This should normally be 1 minute.
login-timeout=Login timeout

View file

@ -195,6 +195,9 @@ module.config([ '$routeProvider', function($routeProvider) {
.when('/realms/:realm/token-settings', {
templateUrl : resourceUrl + '/partials/realm-tokens.html',
resolve : {
serverInfo : function(ServerInfoLoader) {
return ServerInfoLoader();
},
realm : function(RealmLoader) {
return RealmLoader();
}

View file

@ -1065,8 +1065,10 @@ module.controller('RealmIdentityProviderExportCtrl', function(realm, identityPro
}
});
module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $location, $route, Dialog, Notifications, TimeUnit, TimeUnit2) {
module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $location, $route, Dialog, Notifications, TimeUnit, TimeUnit2, serverInfo) {
$scope.realm = realm;
$scope.serverInfo = serverInfo;
$scope.actionTokenProviders = $scope.serverInfo.providers.actionTokenHandler.providers;
$scope.realm.accessTokenLifespan = TimeUnit2.asUnit(realm.accessTokenLifespan);
$scope.realm.accessTokenLifespanForImplicitFlow = TimeUnit2.asUnit(realm.accessTokenLifespanForImplicitFlow);
@ -1078,6 +1080,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction);
$scope.realm.actionTokenGeneratedByAdminLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan);
$scope.realm.actionTokenGeneratedByUserLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByUserLifespan);
$scope.realm.attributes = realm.attributes
var oldCopy = angular.copy($scope.realm);
$scope.changed = false;
@ -1088,6 +1091,17 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
}
}, true);
$scope.$watch('actionLifespanId', function () {
$scope.actionTokenAttribute = TimeUnit2.asUnit($scope.realm.attributes['actionTokenGeneratedByUserLifespan.' + $scope.actionLifespanId]);
}, true);
$scope.$watch('actionTokenAttribute', function () {
if ($scope.actionLifespanId != null && $scope.actionTokenAttribute != null) {
$scope.changed = true;
$scope.realm.attributes['actionTokenGeneratedByUserLifespan.' + $scope.actionLifespanId] = $scope.actionTokenAttribute.toSeconds();
}
}, true);
$scope.changeRevokeRefreshToken = function() {
};
@ -1110,6 +1124,13 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
});
};
$scope.resetToDefaultToken = function (actionTokenId) {
$scope.actionTokenAttribute = {};
delete $scope.realm.attributes['actionTokenGeneratedByUserLifespan.' + $scope.actionLifespanId];
//Only for UI effects, resets to the original state
$scope.actionTokenAttribute.unit = 'Minutes';
}
$scope.reset = function() {
$route.reload();
};

View file

@ -187,6 +187,32 @@
</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="actionTokenAttributeSelect" class="two-lines">
{{:: 'action-token-generated-by-user.operation' | translate }} </label>
<div class="form-inline col-md-6 time-selector">
<select class="form-control" name="actionTokenAttributeSelect" id="actionTokenAttributeSelect"
ng-model="actionLifespanId">
<option value="" disabled selected>{{:: 'select-one.placeholder' | translate}}</option>
<option ng-repeat="(actionTokenId, value) in actionTokenProviders" value="{{actionTokenId}}">
{{:: 'action-token-generated-by-user.' + actionTokenId | translate }}
</option>
</select>
<input class="form-control" type="number" min="1" max="31536000" data-ng-model="actionTokenAttribute.time"
id="actionTokenAttributeTime" name="actionTokenAttributeTime">
<select class="form-control" name="actionTokenAttributeUnit"
data-ng-model="actionTokenAttribute.unit">
<option value="Minutes" ng-selected="true">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
<button data-ng-click="resetToDefaultToken(actionTokenId)">{{:: 'action-token-generated-by-user.reset' | translate}}</button>
</div>
<kc-tooltip>
{{:: 'action-token-generated-by-user.tooltip' | translate}}
</kc-tooltip>
</div>
<div class="form-group">
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>