From ae4bd42ff7754174687f81a77c549f5e5229bf3a Mon Sep 17 00:00:00 2001 From: mposolda Date: Thu, 19 Sep 2013 22:12:52 +0200 Subject: [PATCH] Add support for TOTP in MongoDB --- .../adapters/MongoDBSessionFactory.java | 2 + .../nosql/keycloak/adapters/RealmAdapter.java | 16 +-- .../PasswordCredentialHandler.java | 11 +- .../credentials/TOTPCredentialHandler.java | 129 +++++++++++++++++- .../keycloak/data/credentials/OTPData.java | 66 +++++++++ .../managers/AuthenticationManagerTest.java | 22 +-- 6 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/models/nosql/keycloak/data/credentials/OTPData.java diff --git a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/MongoDBSessionFactory.java b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/MongoDBSessionFactory.java index 73ba4615b7..6f1d7f760e 100644 --- a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/MongoDBSessionFactory.java +++ b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/MongoDBSessionFactory.java @@ -20,6 +20,7 @@ import org.keycloak.services.models.nosql.keycloak.data.SocialLinkData; import org.keycloak.services.models.nosql.keycloak.data.UserData; import org.keycloak.services.models.nosql.impl.MongoDBImpl; import org.keycloak.services.models.nosql.impl.MongoDBQueryBuilder; +import org.keycloak.services.models.nosql.keycloak.data.credentials.OTPData; import org.keycloak.services.models.nosql.keycloak.data.credentials.PasswordData; /** @@ -36,6 +37,7 @@ public class MongoDBSessionFactory implements KeycloakSessionFactory { RoleData.class, RequiredCredentialData.class, PasswordData.class, + OTPData.class, SocialLinkData.class, ApplicationData.class }; diff --git a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/RealmAdapter.java b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/RealmAdapter.java index 24b0146867..4d613d50c4 100644 --- a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/RealmAdapter.java +++ b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/adapters/RealmAdapter.java @@ -32,6 +32,8 @@ import org.keycloak.services.models.nosql.keycloak.data.RoleData; import org.keycloak.services.models.nosql.keycloak.data.SocialLinkData; import org.keycloak.services.models.nosql.keycloak.data.UserData; import org.picketlink.idm.credential.Credentials; +import org.picketlink.idm.credential.Password; +import org.picketlink.idm.credential.TOTPCredentials; /** * @author Marek Posolda @@ -44,9 +46,9 @@ public class RealmAdapter implements RealmModel { protected volatile transient PublicKey publicKey; protected volatile transient PrivateKey privateKey; - // TODO: likely shouldn't be static. And setup is not called ATM, which means that it's not possible to configure stuff like PasswordEncoder etc. - private static PasswordCredentialHandler passwordCredentialHandler = new PasswordCredentialHandler(); - private static TOTPCredentialHandler totpCredentialHandler = new TOTPCredentialHandler(); + // TODO: likely shouldn't be static. And ATM, just empty map is passed -> It's not possible to configure stuff like PasswordEncoder etc. + private static PasswordCredentialHandler passwordCredentialHandler = new PasswordCredentialHandler(new HashMap()); + private static TOTPCredentialHandler totpCredentialHandler = new TOTPCredentialHandler(new HashMap()); public RealmAdapter(RealmData realmData, NoSQL noSQL) { this.realm = realmData; @@ -659,7 +661,8 @@ public class RealmAdapter implements RealmModel { @Override public boolean validateTOTP(UserModel user, String password, String token) { - return false; //To change body of implemented methods use File | Settings | File Templates. + Credentials.Status status = totpCredentialHandler.validate(noSQL, ((UserAdapter)user).getUser(), password, token, null); + return status == Credentials.Status.VALID; } @Override @@ -667,10 +670,7 @@ public class RealmAdapter implements RealmModel { if (cred.getType().equals(CredentialRepresentation.PASSWORD)) { passwordCredentialHandler.update(noSQL, ((UserAdapter)user).getUser(), cred.getValue(), null, null); } else if (cred.getType().equals(CredentialRepresentation.TOTP)) { - // TODO -// TOTPCredential totp = new TOTPCredential(cred.getValue()); -// totp.setDevice(cred.getDevice()); -// idm.updateCredential(((UserAdapter)user).getUser(), totp); + totpCredentialHandler.update(noSQL, ((UserAdapter)user).getUser(), cred.getValue(), cred.getDevice(), null, null); } else if (cred.getType().equals(CredentialRepresentation.CLIENT_CERT)) { // TODO // X509Certificate cert = null; diff --git a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/PasswordCredentialHandler.java b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/PasswordCredentialHandler.java index 77099873dd..52d706e7f6 100644 --- a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/PasswordCredentialHandler.java +++ b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/PasswordCredentialHandler.java @@ -1,15 +1,11 @@ package org.keycloak.services.models.nosql.keycloak.credentials; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import java.util.Date; import java.util.Map; import java.util.UUID; import org.keycloak.services.models.nosql.api.NoSQL; import org.keycloak.services.models.nosql.api.query.NoSQLQuery; -import org.keycloak.services.models.nosql.api.query.NoSQLQueryBuilder; -import org.keycloak.services.models.nosql.impl.MongoDBQueryBuilder; import org.keycloak.services.models.nosql.keycloak.data.UserData; import org.keycloak.services.models.nosql.keycloak.data.credentials.PasswordData; import org.picketlink.idm.credential.Credentials; @@ -34,7 +30,11 @@ public class PasswordCredentialHandler { private PasswordEncoder passwordEncoder = new SHAPasswordEncoder(512);; - public void setup(Map options) { + public PasswordCredentialHandler(Map options) { + setup(options); + } + + private void setup(Map options) { if (options != null) { Object providedEncoder = options.get(PASSWORD_ENCODER); @@ -64,6 +64,7 @@ public class PasswordCredentialHandler { // If the stored hash is null we automatically fail validation if (passwordData != null) { + // TODO: Status.INVALID should have bigger priority than Status.EXPIRED? if (!isCredentialExpired(passwordData.getExpiryDate())) { boolean matches = this.passwordEncoder.verify(saltPassword(passwordToValidate, passwordData.getSalt()), passwordData.getEncodedHash()); diff --git a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/TOTPCredentialHandler.java b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/TOTPCredentialHandler.java index a5dc8d2e44..84b5f0a5e3 100644 --- a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/TOTPCredentialHandler.java +++ b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/credentials/TOTPCredentialHandler.java @@ -1,5 +1,21 @@ package org.keycloak.services.models.nosql.keycloak.credentials; +import java.util.Date; +import java.util.Map; + +import org.keycloak.services.models.nosql.api.NoSQL; +import org.keycloak.services.models.nosql.api.query.NoSQLQuery; +import org.keycloak.services.models.nosql.keycloak.data.UserData; +import org.keycloak.services.models.nosql.keycloak.data.credentials.OTPData; +import org.picketlink.idm.credential.Credentials; +import org.picketlink.idm.credential.util.TimeBasedOTP; + +import static org.picketlink.common.util.StringUtil.isNullOrEmpty; +import static org.picketlink.idm.credential.util.TimeBasedOTP.DEFAULT_ALGORITHM; +import static org.picketlink.idm.credential.util.TimeBasedOTP.DEFAULT_DELAY_WINDOW; +import static org.picketlink.idm.credential.util.TimeBasedOTP.DEFAULT_INTERVAL_SECONDS; +import static org.picketlink.idm.credential.util.TimeBasedOTP.DEFAULT_NUMBER_DIGITS; + /** * Defacto forked from {@link org.picketlink.idm.credential.handler.TOTPCredentialHandler} * @@ -7,5 +23,116 @@ package org.keycloak.services.models.nosql.keycloak.credentials; */ public class TOTPCredentialHandler extends PasswordCredentialHandler { - // TODO: implement + public static final String ALGORITHM = "ALGORITHM"; + public static final String INTERVAL_SECONDS = "INTERVAL_SECONDS"; + public static final String NUMBER_DIGITS = "NUMBER_DIGITS"; + public static final String DELAY_WINDOW = "DELAY_WINDOW"; + public static final String DEFAULT_DEVICE = "DEFAULT_DEVICE"; + + private TimeBasedOTP totp; + + public TOTPCredentialHandler(Map options) { + super(options); + setup(options); + } + + private void setup(Map options) { + String algorithm = getConfigurationProperty(options, ALGORITHM, DEFAULT_ALGORITHM); + String intervalSeconds = getConfigurationProperty(options, INTERVAL_SECONDS, "" + DEFAULT_INTERVAL_SECONDS); + String numberDigits = getConfigurationProperty(options, NUMBER_DIGITS, "" + DEFAULT_NUMBER_DIGITS); + String delayWindow = getConfigurationProperty(options, DELAY_WINDOW, "" + DEFAULT_DELAY_WINDOW); + + this.totp = new TimeBasedOTP(algorithm, Integer.parseInt(numberDigits), Integer.valueOf(intervalSeconds), Integer.valueOf(delayWindow)); + } + + public Credentials.Status validate(NoSQL noSQL, UserData user, String passwordToValidate, String token, String device) { + Credentials.Status status = super.validate(noSQL, user, passwordToValidate); + + if (Credentials.Status.VALID != status) { + return status; + } + + device = getDevice(device); + + user = noSQL.loadObject(UserData.class, user.getId()); + + // If the user for the provided username cannot be found we fail validation + if (user != null) { + if (user.isEnabled()) { + + // Try to find OTP based on userId and device (For now assume that this is unique combination) + NoSQLQuery query = noSQL.createQueryBuilder() + .andCondition("userId", user.getId()) + .andCondition("device", device) + .build(); + OTPData otpData = noSQL.loadSingleObject(OTPData.class, query); + + // If the stored OTP is null we automatically fail validation + if (otpData != null) { + // TODO: Status.INVALID should have bigger priority than Status.EXPIRED? + if (!PasswordCredentialHandler.isCredentialExpired(otpData.getExpiryDate())) { + boolean isValid = this.totp.validate(token, otpData.getSecretKey().getBytes()); + if (!isValid) { + status = Credentials.Status.INVALID; + } + } else { + status = Credentials.Status.EXPIRED; + } + } else { + status = Credentials.Status.UNVALIDATED; + } + } else { + status = Credentials.Status.ACCOUNT_DISABLED; + } + } else { + status = Credentials.Status.INVALID; + } + + return status; + } + + public void update(NoSQL noSQL, UserData user, String secret, String device, Date effectiveDate, Date expiryDate) { + device = getDevice(device); + + // Try to look if user already has otp (Right now, supports just one OTP per user) + NoSQLQuery query = noSQL.createQueryBuilder() + .andCondition("userId", user.getId()) + .andCondition("device", device) + .build(); + + OTPData otpData = noSQL.loadSingleObject(OTPData.class, query); + if (otpData == null) { + otpData = new OTPData(); + } + + otpData.setSecretKey(secret); + otpData.setDevice(device); + + if (effectiveDate != null) { + otpData.setEffectiveDate(effectiveDate); + } + + otpData.setExpiryDate(expiryDate); + otpData.setUserId(user.getId()); + + noSQL.saveObject(otpData); + } + + private String getDevice(String device) { + if (isNullOrEmpty(device)) { + device = DEFAULT_DEVICE; + } + + return device; + } + + private String getConfigurationProperty(Map options, String key, String defaultValue) { + Object value = options.get(key); + + if (value != null) { + return String.valueOf(value); + } + + return defaultValue; + } } diff --git a/services/src/main/java/org/keycloak/services/models/nosql/keycloak/data/credentials/OTPData.java b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/data/credentials/OTPData.java new file mode 100644 index 0000000000..cf4fef864f --- /dev/null +++ b/services/src/main/java/org/keycloak/services/models/nosql/keycloak/data/credentials/OTPData.java @@ -0,0 +1,66 @@ +package org.keycloak.services.models.nosql.keycloak.data.credentials; + +import java.util.Date; + +import org.keycloak.services.models.nosql.api.AbstractNoSQLObject; +import org.keycloak.services.models.nosql.api.NoSQLCollection; +import org.keycloak.services.models.nosql.api.NoSQLField; + +/** + * @author Marek Posolda + */ +@NoSQLCollection(collectionName = "otpCredentials") +public class OTPData extends AbstractNoSQLObject { + + private Date effectiveDate = new Date(); + private Date expiryDate; + private String secretKey; + private String device; + + private String userId; + + @NoSQLField + public Date getEffectiveDate() { + return effectiveDate; + } + + public void setEffectiveDate(Date effectiveDate) { + this.effectiveDate = effectiveDate; + } + + @NoSQLField + public Date getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Date expiryDate) { + this.expiryDate = expiryDate; + } + + @NoSQLField + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + @NoSQLField + public String getDevice() { + return device; + } + + public void setDevice(String device) { + this.device = device; + } + + @NoSQLField + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } +} diff --git a/services/src/test/java/org/keycloak/services/managers/AuthenticationManagerTest.java b/services/src/test/java/org/keycloak/services/managers/AuthenticationManagerTest.java index ce3ed3d8c1..a2049fd8bd 100755 --- a/services/src/test/java/org/keycloak/services/managers/AuthenticationManagerTest.java +++ b/services/src/test/java/org/keycloak/services/managers/AuthenticationManagerTest.java @@ -18,24 +18,20 @@ import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.services.resources.KeycloakApplication; +import org.keycloak.test.common.AbstractKeycloakTest; +import org.keycloak.test.common.SessionFactoryTestContext; import org.picketlink.idm.credential.util.TimeBasedOTP; -public class AuthenticationManagerTest { +public class AuthenticationManagerTest extends AbstractKeycloakTest { - private RealmManager adapter; private AuthenticationManager am; - private KeycloakSessionFactory factory; private MultivaluedMap formData; - private KeycloakSession identitySession; private TimeBasedOTP otp; private RealmModel realm; private UserModel user; - @After - public void after() throws Exception { - identitySession.getTransaction().commit(); - identitySession.close(); - factory.close(); + public AuthenticationManagerTest(SessionFactoryTestContext testContext) { + super(testContext); } @Test @@ -134,12 +130,8 @@ public class AuthenticationManagerTest { @Before public void before() throws Exception { - factory = KeycloakApplication.buildSessionFactory(); - identitySession = factory.createSession(); - identitySession.getTransaction().begin(); - adapter = new RealmManager(identitySession); - - realm = adapter.createRealm("Test"); + super.before(); + realm = getRealmManager().createRealm("Test"); realm.setAccessCodeLifespan(100); realm.setCookieLoginAllowed(true); realm.setEnabled(true);