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;