diff --git a/core/src/main/java/org/keycloak/representations/PasswordToken.java b/core/src/main/java/org/keycloak/representations/PasswordToken.java new file mode 100644 index 0000000000..8ae9ffcd79 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/PasswordToken.java @@ -0,0 +1,47 @@ +package org.keycloak.representations; + +import org.keycloak.util.Time; + +/** + * @author Stian Thorgersen + */ +public class PasswordToken { + + private String realm; + private String user; + private int timestamp; + + public PasswordToken() { + } + + public PasswordToken(String realm, String user) { + this.realm = realm; + this.user = user; + this.timestamp = Time.currentTime(); + } + + public String getRealm() { + return realm; + } + + public void setRealm(String realm) { + this.realm = realm; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public int getTimestamp() { + return timestamp; + } + + public void setTimestamp(int timestamp) { + this.timestamp = timestamp; + } + +} diff --git a/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java index 406ded3ac1..f1ea7153c2 100755 --- a/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java @@ -7,6 +7,7 @@ package org.keycloak.representations.idm; public class CredentialRepresentation { public static final String SECRET = "secret"; public static final String PASSWORD = "password"; + public static final String PASSWORD_TOKEN = "password-token"; public static final String TOTP = "totp"; public static final String CLIENT_CERT = "cert"; diff --git a/forms/common-themes/src/main/resources/theme/login/base/login-totp.ftl b/forms/common-themes/src/main/resources/theme/login/base/login-totp.ftl index 11275649b9..613e8f2593 100755 --- a/forms/common-themes/src/main/resources/theme/login/base/login-totp.ftl +++ b/forms/common-themes/src/main/resources/theme/login/base/login-totp.ftl @@ -7,7 +7,7 @@ <#elseif section = "form">
- +
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/LoginBean.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/LoginBean.java index d54f5b460c..22dda679d0 100755 --- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/LoginBean.java +++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/LoginBean.java @@ -32,10 +32,13 @@ public class LoginBean { private String password; + private String passwordToken; + public LoginBean(MultivaluedMap formData){ if (formData != null) { username = formData.getFirst("username"); password = formData.getFirst("password"); + passwordToken = formData.getFirst("password-token"); } } @@ -47,4 +50,7 @@ public class LoginBean { return password; } + public String getPasswordToken() { + return passwordToken; + } } diff --git a/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java b/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java index 4c5275b923..2f1fbf7aa2 100755 --- a/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java @@ -8,6 +8,7 @@ import java.util.UUID; */ public class UserCredentialModel { public static final String PASSWORD = "password"; + public static final String PASSWORD_TOKEN = "password-token"; // Secret is same as password but it is not hashed public static final String SECRET = "secret"; @@ -27,6 +28,12 @@ public class UserCredentialModel { model.setValue(password); return model; } + public static UserCredentialModel passwordToken(String passwordToken) { + UserCredentialModel model = new UserCredentialModel(); + model.setType(PASSWORD_TOKEN); + model.setValue(passwordToken); + return model; + } public static UserCredentialModel secret(String password) { UserCredentialModel model = new UserCredentialModel(); diff --git a/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java b/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java index 901571ff3f..a596c823d5 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java +++ b/model/api/src/main/java/org/keycloak/models/utils/CredentialValidation.java @@ -1,11 +1,16 @@ package org.keycloak.models.utils; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialValueModel; import org.keycloak.models.UserModel; +import org.keycloak.representations.PasswordToken; +import org.keycloak.util.Time; +import java.io.IOException; import java.util.List; /** @@ -57,6 +62,28 @@ public class CredentialValidation { } + public static boolean validPasswordToken(RealmModel realm, UserModel user, String encodedPasswordToken) { + JWSInput jws = new JWSInput(encodedPasswordToken); + if (!RSAProvider.verify(jws, realm.getPublicKey())) { + return false; + } + try { + PasswordToken passwordToken = jws.readJsonContent(PasswordToken.class); + if (!passwordToken.getRealm().equals(realm.getName())) { + return false; + } + if (!passwordToken.getUser().equals(user.getId())) { + return false; + } + if (Time.currentTime() - passwordToken.getTimestamp() > realm.getAccessCodeLifespanUserAction()) { + return false; + } + return true; + } catch (IOException e) { + return false; + } + } + public static boolean validTOTP(RealmModel realm, UserModel user, String otp) { UserCredentialValueModel passwordCred = null; for (UserCredentialValueModel cred : user.getCredentialsDirectly()) { @@ -114,6 +141,10 @@ public class CredentialValidation { if (!validPassword(realm, user, credential.getValue())) { return false; } + } else if (credential.getType().equals(UserCredentialModel.PASSWORD_TOKEN)) { + if (!validPasswordToken(realm, user, credential.getValue())) { + return false; + } } else if (credential.getType().equals(UserCredentialModel.TOTP)) { if (!validTOTP(realm, user, credential.getValue())) { return false; diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 75e7d8ecec..3930df1d68 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -265,29 +265,37 @@ public class AuthenticationManager { if (types.contains(CredentialRepresentation.PASSWORD)) { List credentials = new LinkedList(); + String password = formData.getFirst(CredentialRepresentation.PASSWORD); - if (password == null) { + if (password != null) { + credentials.add(UserCredentialModel.password(password)); + } + + String passwordToken = formData.getFirst(CredentialRepresentation.PASSWORD_TOKEN); + if (passwordToken != null) { + credentials.add(UserCredentialModel.passwordToken(passwordToken)); + } + + String totp = formData.getFirst(CredentialRepresentation.TOTP); + if (totp != null) { + credentials.add(UserCredentialModel.totp(totp)); + } + + if (password == null && passwordToken == null) { logger.debug("Password not provided"); return AuthenticationStatus.MISSING_PASSWORD; } - credentials.add(UserCredentialModel.password(password)); - if (user.isTotp()) { - String token = formData.getFirst(CredentialRepresentation.TOTP); - if (token == null) { - logger.debug("TOTP token not provided"); - return AuthenticationStatus.MISSING_TOTP; - } - credentials.add(UserCredentialModel.totp(token)); - - } - - logger.debug("validating password for user: " + username); + logger.debugv("validating password for user: {0}", username); if (!session.users().validCredentials(realm, user, credentials)) { return AuthenticationStatus.INVALID_CREDENTIALS; } + if (user.isTotp() && totp == null) { + return AuthenticationStatus.MISSING_TOTP; + } + if (!user.getRequiredActions().isEmpty()) { return AuthenticationStatus.ACTIONS_REQUIRED; } else { diff --git a/services/src/main/java/org/keycloak/services/resources/TokenService.java b/services/src/main/java/org/keycloak/services/resources/TokenService.java index 61e47b44c8..a35ebf8786 100755 --- a/services/src/main/java/org/keycloak/services/resources/TokenService.java +++ b/services/src/main/java/org/keycloak/services/resources/TokenService.java @@ -17,6 +17,7 @@ import org.keycloak.events.EventBuilder; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; +import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.login.LoginFormsProvider; import org.keycloak.models.ApplicationModel; import org.keycloak.models.ClientModel; @@ -38,6 +39,7 @@ import org.keycloak.services.ForbiddenException; import org.keycloak.services.managers.AccessCode; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager.AuthenticationStatus; +import org.keycloak.representations.PasswordToken; import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.services.managers.TokenManager; import org.keycloak.services.messages.Messages; @@ -545,6 +547,11 @@ public class TokenService { event.error(Errors.USER_DISABLED); return Flows.forms(this.session, realm, client, uriInfo).setError(Messages.ACCOUNT_DISABLED).setFormData(formData).createLogin(); case MISSING_TOTP: + formData.remove(CredentialRepresentation.PASSWORD); + + String passwordToken = new JWSBuilder().jsonContent(new PasswordToken(realm.getName(), user.getId())).rsa256(realm.getPrivateKey()); + formData.add(CredentialRepresentation.PASSWORD_TOKEN, passwordToken); + return Flows.forms(this.session, realm, client, uriInfo).setFormData(formData).createLoginTotp(); case INVALID_USER: event.error(Errors.USER_NOT_FOUND); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/LoginTotpTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/LoginTotpTest.java index f53e85a327..78b52497e6 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/LoginTotpTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/LoginTotpTest.java @@ -94,6 +94,8 @@ public class LoginTotpTest { private TimeBasedOTP totp = new TimeBasedOTP(); + private int lifespan; + @Before public void before() throws MalformedURLException { totp = new TimeBasedOTP(); @@ -133,14 +135,45 @@ public class LoginTotpTest { loginPage.open(); loginPage.login("test-user@localhost", "invalid"); - loginTotpPage.assertCurrent(); - - loginTotpPage.login(totp.generate("totpSecret")); - loginPage.assertCurrent(); + Assert.assertEquals("Invalid username or password.", loginPage.getError()); events.expectLogin().error("invalid_user_credentials").removeDetail(Details.CODE_ID).session((String) null).assertEvent(); } + @Test + public void loginWithTotpExpiredPasswordToken() throws Exception { + try { + keycloakRule.configure(new KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + lifespan = appRealm.getAccessCodeLifespanUserAction(); + appRealm.setAccessCodeLifespanUserAction(1); + } + }); + + loginPage.open(); + loginPage.login("test-user@localhost", "password"); + + loginTotpPage.assertCurrent(); + + Thread.sleep(2000); + + loginTotpPage.login(totp.generate("totpSecret")); + + loginPage.assertCurrent(); + Assert.assertEquals("Invalid username or password.", loginPage.getError()); + + events.expectLogin().error("invalid_user_credentials").removeDetail(Details.CODE_ID).session((String) null).assertEvent(); + } finally { + keycloakRule.configure(new KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + appRealm.setAccessCodeLifespanUserAction(lifespan); + } + }); + } + } + } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/AuthenticationManagerTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/AuthenticationManagerTest.java index 24a473df7a..16ff953998 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/AuthenticationManagerTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/AuthenticationManagerTest.java @@ -6,15 +6,19 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.keycloak.ClientConnection; +import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.models.RealmModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.RequiredAction; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.representations.PasswordToken; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager.AuthenticationStatus; import org.keycloak.services.managers.BruteForceProtector; +import org.keycloak.services.managers.RealmManager; import javax.ws.rs.core.MultivaluedMap; import java.util.UUID; @@ -117,7 +121,7 @@ public class AuthenticationManagerTest extends AbstractModelTest { } @Test - public void authFormWithToltpInvalidPassword() { + public void authFormWithTotpInvalidPassword() { authFormWithTotp(); formData.remove(CredentialRepresentation.PASSWORD); @@ -127,6 +131,16 @@ public class AuthenticationManagerTest extends AbstractModelTest { Assert.assertEquals(AuthenticationStatus.INVALID_CREDENTIALS, status); } + @Test + public void authFormWithTotpMissingPassword() { + authFormWithTotp(); + + formData.remove(CredentialRepresentation.PASSWORD); + + AuthenticationStatus status = am.authenticateForm(session, dummyConnection, realm, formData); + Assert.assertEquals(AuthenticationStatus.MISSING_PASSWORD, status); + } + @Test public void authFormWithTotpInvalidTotp() { authFormWithTotp(); @@ -148,6 +162,92 @@ public class AuthenticationManagerTest extends AbstractModelTest { Assert.assertEquals(AuthenticationStatus.MISSING_TOTP, status); } + @Test + public void authFormWithTotpPasswordToken() { + realm.addRequiredCredential(CredentialRepresentation.TOTP); + + String totpSecret = UUID.randomUUID().toString(); + + UserCredentialModel credential = new UserCredentialModel(); + credential.setType(CredentialRepresentation.TOTP); + credential.setValue(totpSecret); + + user.updateCredential(credential); + + user.setTotp(true); + + String token = otp.generate(totpSecret); + + formData.add(CredentialRepresentation.TOTP, token); + formData.remove(CredentialRepresentation.PASSWORD); + + String passwordToken = new JWSBuilder().jsonContent(new PasswordToken(realm.getName(), user.getId())).rsa256(realm.getPrivateKey()); + formData.add(CredentialRepresentation.PASSWORD_TOKEN, passwordToken); + + AuthenticationStatus status = am.authenticateForm(session, dummyConnection, realm, formData); + Assert.assertEquals(AuthenticationStatus.SUCCESS, status); + } + + @Test + public void authFormWithTotpPasswordTokenInvalidKey() { + authFormWithTotpPasswordToken(); + + formData.remove(CredentialRepresentation.PASSWORD_TOKEN); + String passwordToken = new JWSBuilder().jsonContent(new PasswordToken(realm.getName(), user.getId())).rsa256(realm.getPrivateKey()); + formData.add(CredentialRepresentation.PASSWORD_TOKEN, passwordToken); + + KeycloakModelUtils.generateRealmKeys(realm); + + AuthenticationStatus status = am.authenticateForm(session, dummyConnection, realm, formData); + Assert.assertEquals(AuthenticationStatus.INVALID_CREDENTIALS, status); + } + + @Test + public void authFormWithTotpPasswordTokenInvalidRealm() { + authFormWithTotpPasswordToken(); + + formData.remove(CredentialRepresentation.PASSWORD_TOKEN); + String passwordToken = new JWSBuilder().jsonContent(new PasswordToken("invalid", user.getId())).rsa256(realm.getPrivateKey()); + formData.add(CredentialRepresentation.PASSWORD_TOKEN, passwordToken); + + AuthenticationStatus status = am.authenticateForm(session, dummyConnection, realm, formData); + Assert.assertEquals(AuthenticationStatus.INVALID_CREDENTIALS, status); + } + + @Test + public void authFormWithTotpPasswordTokenInvalidUser() { + authFormWithTotpPasswordToken(); + + formData.remove(CredentialRepresentation.PASSWORD_TOKEN); + String passwordToken = new JWSBuilder().jsonContent(new PasswordToken(realm.getName(), "invalid")).rsa256(realm.getPrivateKey()); + formData.add(CredentialRepresentation.PASSWORD_TOKEN, passwordToken); + + AuthenticationStatus status = am.authenticateForm(session, dummyConnection, realm, formData); + Assert.assertEquals(AuthenticationStatus.INVALID_CREDENTIALS, status); + } + + @Test + public void authFormWithTotpPasswordTokenExpired() throws InterruptedException { + int lifespan = realm.getAccessCodeLifespanUserAction(); + + try { + authFormWithTotpPasswordToken(); + + realm.setAccessCodeLifespanUserAction(1); + + formData.remove(CredentialRepresentation.PASSWORD_TOKEN); + String passwordToken = new JWSBuilder().jsonContent(new PasswordToken(realm.getName(), "invalid")).rsa256(realm.getPrivateKey()); + formData.add(CredentialRepresentation.PASSWORD_TOKEN, passwordToken); + + Thread.sleep(2000); + + AuthenticationStatus status = am.authenticateForm(session, dummyConnection, realm, formData); + Assert.assertEquals(AuthenticationStatus.INVALID_CREDENTIALS, status); + } finally { + realm.setAccessCodeLifespanUserAction(lifespan); + } + } + @Before @Override public void before() throws Exception { @@ -157,8 +257,9 @@ public class AuthenticationManagerTest extends AbstractModelTest { realm.setAccessCodeLifespan(100); realm.setEnabled(true); realm.setName("TestAuth"); - realm.setPrivateKeyPem("0234234"); - realm.setPublicKeyPem("0234234"); + + KeycloakModelUtils.generateRealmKeys(realm); + realm.setAccessTokenLifespan(1000); realm.addRequiredCredential(CredentialRepresentation.PASSWORD); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/LoginTotpPage.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/LoginTotpPage.java index 20361215e7..e1a934a61a 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/LoginTotpPage.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/LoginTotpPage.java @@ -33,6 +33,9 @@ public class LoginTotpPage extends AbstractPage { @FindBy(id = "totp") private WebElement totpInput; + @FindBy(id = "password-token") + private WebElement passwordToken; + @FindBy(css = "input[type=\"submit\"]") private WebElement submitButton;