Merge pull request #652 from stianst/master

Password token
This commit is contained in:
Stian Thorgersen 2014-08-28 14:22:51 +02:00
commit a2171bf777
12 changed files with 266 additions and 30 deletions

View file

@ -0,0 +1,47 @@
package org.keycloak.representations;
import org.keycloak.util.Time;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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;
}
}

View file

@ -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";

View file

@ -7,7 +7,7 @@
<#elseif section = "form">
<form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<input id="username" name="username" value="${login.username!''}" type="hidden" />
<input id="password" name="password" value="${login.password!''}" type="hidden" />
<input id="password-token" name="password-token" value="${login.passwordToken!''}" type="hidden" />
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">

View file

@ -32,10 +32,13 @@ public class LoginBean {
private String password;
private String passwordToken;
public LoginBean(MultivaluedMap<String, String> 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;
}
}

View file

@ -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();

View file

@ -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;

View file

@ -265,29 +265,37 @@ public class AuthenticationManager {
if (types.contains(CredentialRepresentation.PASSWORD)) {
List<UserCredentialModel> credentials = new LinkedList<UserCredentialModel>();
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 {

View file

@ -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);

View file

@ -66,7 +66,6 @@ public class UserFederationResource {
@Path("providers")
@Produces("application/json")
public List<UserFederationProviderFactoryRepresentation> getProviders() {
logger.info("get provider list");
auth.requireView();
List<UserFederationProviderFactoryRepresentation> providers = new LinkedList<UserFederationProviderFactoryRepresentation>();
for (ProviderFactory factory : session.getKeycloakSessionFactory().getProviderFactories(UserFederationProvider.class)) {
@ -75,7 +74,6 @@ public class UserFederationResource {
rep.setOptions(((UserFederationProviderFactory)factory).getConfigurationOptions());
providers.add(rep);
}
logger.info("provider list.size() " + providers.size());
return providers;
}
@ -89,7 +87,6 @@ public class UserFederationResource {
@Path("providers/{id}")
@Produces("application/json")
public UserFederationProviderFactoryRepresentation getProvider(@PathParam("id") String id) {
logger.info("get provider list");
auth.requireView();
for (ProviderFactory factory : session.getKeycloakSessionFactory().getProviderFactories(UserFederationProvider.class)) {
if (!factory.getId().equals(id)) {
@ -113,7 +110,6 @@ public class UserFederationResource {
@Path("instances")
@Consumes("application/json")
public Response createProviderInstance(UserFederationProviderRepresentation rep) {
logger.info("createProvider");
auth.requireManage();
String displayName = rep.getDisplayName();
if (displayName != null && displayName.trim().equals("")) {
@ -136,7 +132,6 @@ public class UserFederationResource {
@Path("instances/{id}")
@Consumes("application/json")
public void updateProviderInstance(@PathParam("id") String id, UserFederationProviderRepresentation rep) {
logger.info("updateProvider");
auth.requireManage();
String displayName = rep.getDisplayName();
if (displayName != null && displayName.trim().equals("")) {
@ -158,7 +153,6 @@ public class UserFederationResource {
@Path("instances/{id}")
@Produces("application/json")
public UserFederationProviderRepresentation getProviderInstance(@PathParam("id") String id) {
logger.info("getProvider");
auth.requireView();
for (UserFederationProviderModel model : realm.getUserFederationProviders()) {
if (model.getId().equals(id)) {
@ -176,7 +170,6 @@ public class UserFederationResource {
@DELETE
@Path("instances/{id}")
public void deleteProviderInstance(@PathParam("id") String id) {
logger.info("deleteProvider");
auth.requireManage();
UserFederationProviderModel model = new UserFederationProviderModel(id, null, null, -1, null, -1, -1, 0);
realm.removeUserFederationProvider(model);
@ -194,7 +187,6 @@ public class UserFederationResource {
@Produces("application/json")
@NoCache
public List<UserFederationProviderRepresentation> getUserFederationInstances() {
logger.info("getUserFederationInstances");
auth.requireManage();
List<UserFederationProviderRepresentation> reps = new LinkedList<UserFederationProviderRepresentation>();
for (UserFederationProviderModel model : realm.getUserFederationProviders()) {
@ -213,7 +205,7 @@ public class UserFederationResource {
@Path("sync/{id}")
@NoCache
public Response syncUsers(@PathParam("id") String providerId, @QueryParam("action") String action) {
logger.info("triggerSync");
logger.debug("Syncing users");
auth.requireManage();
for (UserFederationProviderModel model : realm.getUserFederationProviders()) {

View file

@ -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);
}
});
}
}
}

View file

@ -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);

View file

@ -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;