diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml index 3d4848ce78..1ca78d5513 100755 --- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml @@ -134,5 +134,8 @@ - - + + + + + diff --git a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java index 8607264d04..1d2bee3c63 100755 --- a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java @@ -17,6 +17,7 @@ public class UserRepresentation { protected String self; // link protected String id; + protected Long createdTimestamp; protected String username; protected boolean enabled; protected boolean totp; @@ -56,6 +57,14 @@ public class UserRepresentation { this.id = id; } + public Long getCreatedTimestamp() { + return createdTimestamp; + } + + public void setCreatedTimestamp(Long createdTimestamp) { + this.createdTimestamp = createdTimestamp; + } + public String getFirstName() { return firstName; } diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-list.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-list.html index 36696f769c..5ddd71a0a9 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-list.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-list.html @@ -36,7 +36,7 @@ {{client.clientId}} {{client.enabled}} - {{client.baseUrl}} + {{client.baseUrl}} Not defined diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html index a4e78e8753..6779d4254b 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html @@ -20,6 +20,13 @@ + +
+ +
+ {{user.createdTimestamp|date:'shortDate'}} {{user.createdTimestamp|date:'mediumTime'}} +
+
@@ -118,4 +125,4 @@
- \ No newline at end of file + diff --git a/integration/tomcat/tomcat-core/src/main/java/org/keycloak/adapters/tomcat/AbstractKeycloakAuthenticatorValve.java b/integration/tomcat/tomcat-core/src/main/java/org/keycloak/adapters/tomcat/AbstractKeycloakAuthenticatorValve.java index f415f9f11b..7088b46ef4 100755 --- a/integration/tomcat/tomcat-core/src/main/java/org/keycloak/adapters/tomcat/AbstractKeycloakAuthenticatorValve.java +++ b/integration/tomcat/tomcat-core/src/main/java/org/keycloak/adapters/tomcat/AbstractKeycloakAuthenticatorValve.java @@ -127,7 +127,9 @@ public abstract class AbstractKeycloakAuthenticatorValve extends FormAuthenticat } protected void beforeStop() { - nodesRegistrationManagement.stop(); + if (nodesRegistrationManagement != null) { + nodesRegistrationManagement.stop(); + } } private static InputStream getJSONFromServletContext(ServletContext servletContext) { diff --git a/model/api/src/main/java/org/keycloak/models/UserModel.java b/model/api/src/main/java/org/keycloak/models/UserModel.java index dea9e7b039..f81e155988 100755 --- a/model/api/src/main/java/org/keycloak/models/UserModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserModel.java @@ -20,6 +20,13 @@ public interface UserModel { String getUsername(); void setUsername(String username); + + /** + * Get timestamp of user creation. May be null for old users created before this feature introduction. + */ + Long getCreatedTimestamp(); + + void setCreatedTimestamp(Long timestamp); boolean isEnabled(); @@ -106,4 +113,4 @@ public interface UserModel { public static enum RequiredAction { VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD } -} \ No newline at end of file +} diff --git a/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java b/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java index d21d20310d..bde99eca89 100755 --- a/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java +++ b/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java @@ -10,6 +10,7 @@ import java.util.Map; public class UserEntity extends AbstractIdentifiableEntity { private String username; + private Long createdTimestamp; private String firstName; private String lastName; private String email; @@ -34,6 +35,15 @@ public class UserEntity extends AbstractIdentifiableEntity { public void setUsername(String username) { this.username = username; } + + public Long getCreatedTimestamp() { + return createdTimestamp; + } + + public void setCreatedTimestamp(Long timestamp) { + this.createdTimestamp = timestamp; + } + public String getFirstName() { return firstName; diff --git a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 3bf412c93e..7039262350 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -51,6 +51,7 @@ public class ModelToRepresentation { UserRepresentation rep = new UserRepresentation(); rep.setId(user.getId()); rep.setUsername(user.getUsername()); + rep.setCreatedTimestamp(user.getCreatedTimestamp()); rep.setLastName(user.getLastName()); rep.setFirstName(user.getFirstName()); rep.setEmail(user.getEmail()); diff --git a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 217da6714d..2b7195ad6f 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -799,6 +799,7 @@ public class RepresentationToModel { // Import users just to user storage. Don't federate UserModel user = session.userStorage().addUser(newRealm, userRep.getId(), userRep.getUsername(), false, false); user.setEnabled(userRep.isEnabled()); + user.setCreatedTimestamp(userRep.getCreatedTimestamp()); user.setEmail(userRep.getEmail()); user.setEmailVerified(userRep.isEmailVerified()); user.setFirstName(userRep.getFirstName()); diff --git a/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java b/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java index 699a38e308..5d9cf700d3 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java +++ b/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java @@ -235,4 +235,14 @@ public class UserModelDelegate implements UserModel { public UserModel getDelegate() { return delegate; } + + @Override + public Long getCreatedTimestamp(){ + return delegate.getCreatedTimestamp(); + } + + @Override + public void setCreatedTimestamp(Long timestamp){ + delegate.setCreatedTimestamp(timestamp); + } } diff --git a/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java b/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java index eb28d309bd..94161700f0 100755 --- a/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java +++ b/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java @@ -305,6 +305,7 @@ public class FileUserProvider implements UserProvider { UserEntity userEntity = new UserEntity(); userEntity.setId(userId); + userEntity.setCreatedTimestamp(System.currentTimeMillis()); userEntity.setUsername(username); // Compatibility with JPA model, which has user disabled by default // userEntity.setEnabled(true); diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java index 5c33375b5a..ebafc24af5 100755 --- a/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java +++ b/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java @@ -98,6 +98,16 @@ public class UserAdapter implements UserModel, Comparable { user.setUsername(username); } + @Override + public Long getCreatedTimestamp() { + return user.getCreatedTimestamp(); + } + + @Override + public void setCreatedTimestamp(Long timestamp) { + user.setCreatedTimestamp(timestamp); + } + @Override public boolean isEnabled() { return user.isEnabled(); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/UserAdapter.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/UserAdapter.java index fb516c60f4..5c84b515e0 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/UserAdapter.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/UserAdapter.java @@ -60,6 +60,17 @@ public class UserAdapter implements UserModel { updated.setUsername(username); } + @Override + public Long getCreatedTimestamp() { + // get from cached always as it is immutable + return cached.getCreatedTimestamp(); + } + + @Override + public void setCreatedTimestamp(Long timestamp) { + // nothing to do as this value is immutable + } + @Override public boolean isEnabled() { if (updated != null) return updated.isEnabled(); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java index d7824cc85b..e8f54dbac6 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java @@ -22,6 +22,7 @@ public class CachedUser implements Serializable { private String id; private String realm; private String username; + private Long createdTimestamp; private String firstName; private String lastName; private String email; @@ -34,11 +35,11 @@ public class CachedUser implements Serializable { private Set requiredActions = new HashSet<>(); private Set roleMappings = new HashSet(); - public CachedUser(RealmModel realm, UserModel user) { this.id = user.getId(); this.realm = realm.getId(); this.username = user.getUsername(); + this.createdTimestamp = user.getCreatedTimestamp(); this.firstName = user.getFirstName(); this.lastName = user.getLastName(); this.attributes.putAll(user.getAttributes()); @@ -66,6 +67,10 @@ public class CachedUser implements Serializable { return username; } + public Long getCreatedTimestamp() { + return createdTimestamp; + } + public String getFirstName() { return firstName; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index ca30006d16..b2915ed70c 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -52,6 +52,7 @@ public class JpaUserProvider implements UserProvider { UserEntity entity = new UserEntity(); entity.setId(id); + entity.setCreatedTimestamp(System.currentTimeMillis()); entity.setUsername(username.toLowerCase()); entity.setRealmId(realm.getId()); em.persist(entity); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java index 3719b9dd9a..c8d7fd1182 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java @@ -77,6 +77,16 @@ public class UserAdapter implements UserModel { user.setUsername(username); } + @Override + public Long getCreatedTimestamp() { + return user.getCreatedTimestamp(); + } + + @Override + public void setCreatedTimestamp(Long timestamp) { + user.setCreatedTimestamp(timestamp); + } + @Override public boolean isEnabled() { return user.isEnabled(); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java index d812a3441b..443c8ebfa9 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java @@ -11,6 +11,7 @@ import javax.persistence.NamedQuery; import javax.persistence.OneToMany; import javax.persistence.Table; import javax.persistence.UniqueConstraint; + import java.util.ArrayList; import java.util.Collection; @@ -44,6 +45,8 @@ public class UserEntity { protected String username; @Column(name = "FIRST_NAME") protected String firstName; + @Column(name = "CREATED_TIMESTAMP") + protected Long createdTimestamp; @Column(name = "LAST_NAME") protected String lastName; @Column(name = "EMAIL") @@ -90,6 +93,14 @@ public class UserEntity { this.username = username; } + public Long getCreatedTimestamp() { + return createdTimestamp; + } + + public void setCreatedTimestamp(Long timestamp) { + createdTimestamp = timestamp; + } + public String getFirstName() { return firstName; } diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java index 6c692de3c3..efee051e85 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java @@ -3,6 +3,7 @@ package org.keycloak.models.mongo.keycloak.adapters; import com.mongodb.BasicDBObject; import com.mongodb.DBObject; import com.mongodb.QueryBuilder; + import org.keycloak.connections.mongo.api.MongoStore; import org.keycloak.connections.mongo.api.context.MongoStoreInvocationContext; import org.keycloak.models.ClientModel; @@ -274,6 +275,7 @@ public class MongoUserProvider implements UserProvider { MongoUserEntity userEntity = new MongoUserEntity(); userEntity.setId(id); userEntity.setUsername(username); + userEntity.setCreatedTimestamp(System.currentTimeMillis()); // Compatibility with JPA model, which has user disabled by default // userEntity.setEnabled(true); userEntity.setRealmId(realm.getId()); diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java index dc858ef982..e087225c61 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java @@ -4,6 +4,7 @@ import static org.keycloak.models.utils.Pbkdf2PasswordEncoder.getSalt; import com.mongodb.DBObject; import com.mongodb.QueryBuilder; + import org.keycloak.connections.mongo.api.context.MongoStoreInvocationContext; import org.keycloak.models.ClientModel; import org.keycloak.models.ProtocolMapperModel; @@ -70,6 +71,16 @@ public class UserAdapter extends AbstractMongoAdapter implement updateUser(); } + @Override + public Long getCreatedTimestamp() { + return user.getCreatedTimestamp(); + } + + @Override + public void setCreatedTimestamp(Long timestamp) { + user.setCreatedTimestamp(timestamp); + } + @Override public boolean isEnabled() { return user.isEnabled(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java index ba6d44bcf0..b883226ed8 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java @@ -686,6 +686,9 @@ public abstract class AbstractIdentityProviderTest { UserModel federatedUser = getFederatedUser(); assertNotNull(federatedUser); + assertNotNull(federatedUser.getCreatedTimestamp()); + // test that timestamp is current with 10s tollerance + Assert.assertTrue((System.currentTimeMillis() - federatedUser.getCreatedTimestamp()) < 10000); doAssertFederatedUser(federatedUser, identityProviderModel, expectedEmail, isProfileUpdateExpected); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java index 7c3143aeb9..d134378361 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java @@ -26,11 +26,15 @@ import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.keycloak.events.Details; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.IDToken; import org.keycloak.services.managers.RealmManager; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.OAuthClient; +import org.keycloak.testsuite.broker.util.UserSessionStatusServlet.UserSessionStatus; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.LoginPage; @@ -208,6 +212,22 @@ public class RegisterTest { String userId = events.expectRegister("registerUserSuccess", "registerUserSuccess@email").assertEvent().getUserId(); events.expectLogin().detail("username", "registerusersuccess").user(userId).assertEvent(); + + UserModel user = getUser(userId); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getCreatedTimestamp()); + // test that timestamp is current with 10s tollerance + Assert.assertTrue((System.currentTimeMillis() - user.getCreatedTimestamp()) < 10000); + } + + protected UserModel getUser(String userId) { + KeycloakSession samlServerSession = keycloakRule.startSession(); + try { + RealmModel brokerRealm = samlServerSession.realms().getRealm("test"); + return samlServerSession.users().getUserById(userId, brokerRealm); + } finally { + keycloakRule.stopSession(samlServerSession, false); + } } @Test @@ -268,6 +288,13 @@ public class RegisterTest { String userId = events.expectRegister("registerUserSuccessE@email", "registerUserSuccessE@email").assertEvent().getUserId(); events.expectLogin().detail("username", "registerusersuccesse@email").user(userId).assertEvent(); + + UserModel user = getUser(userId); + Assert.assertNotNull(user); + Assert.assertNotNull(user.getCreatedTimestamp()); + // test that timestamp is current with 10s tollerance + Assert.assertTrue((System.currentTimeMillis() - user.getCreatedTimestamp()) < 10000); + } finally { configureRelamRegistrationEmailAsUsername(false); } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java index e28d2b39aa..86ca62e5eb 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java @@ -117,6 +117,8 @@ public class ImportTest extends AbstractModelTest { // Test role mappings UserModel admin = session.users().getUserByUsername("admin", realm); + // user without creation timestamp in import + Assert.assertNull(admin.getCreatedTimestamp()); Set allRoles = admin.getRoleMappings(); Assert.assertEquals(3, allRoles.size()); Assert.assertTrue(allRoles.contains(realm.getRole("admin"))); @@ -124,6 +126,8 @@ public class ImportTest extends AbstractModelTest { Assert.assertTrue(allRoles.contains(otherApp.getRole("otherapp-admin"))); UserModel wburke = session.users().getUserByUsername("wburke", realm); + // user with creation timestamp in import + Assert.assertEquals(new Long(123654), wburke.getCreatedTimestamp()); allRoles = wburke.getRoleMappings(); Assert.assertEquals(2, allRoles.size()); Assert.assertFalse(allRoles.contains(realm.getRole("admin"))); @@ -132,6 +136,10 @@ public class ImportTest extends AbstractModelTest { Assert.assertEquals(0, wburke.getRealmRoleMappings().size()); + UserModel loginclient = session.users().getUserByUsername("loginclient", realm); + // user with creation timestamp as string in import + Assert.assertEquals(new Long(123655), loginclient.getCreatedTimestamp()); + Set realmRoles = admin.getRealmRoleMappings(); Assert.assertEquals(1, realmRoles.size()); Assert.assertEquals("admin", realmRoles.iterator().next().getName()); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java index ff7429303b..58196f61be 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java @@ -8,6 +8,8 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.RequiredAction; +import static org.junit.Assert.assertNotNull; + import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -27,6 +29,9 @@ public class UserModelTest extends AbstractModelTest { user.setFirstName("first-name"); user.setLastName("last-name"); user.setEmail("email"); + assertNotNull(user.getCreatedTimestamp()); + // test that timestamp is current with 10s tollerance + Assert.assertTrue((System.currentTimeMillis() - user.getCreatedTimestamp()) < 10000); user.addRequiredAction(RequiredAction.CONFIGURE_TOTP); user.addRequiredAction(RequiredAction.UPDATE_PASSWORD); @@ -195,6 +200,7 @@ public class UserModelTest extends AbstractModelTest { public static void assertEquals(UserModel expected, UserModel actual) { Assert.assertEquals(expected.getUsername(), actual.getUsername()); + Assert.assertEquals(expected.getCreatedTimestamp(), actual.getCreatedTimestamp()); Assert.assertEquals(expected.getFirstName(), actual.getFirstName()); Assert.assertEquals(expected.getLastName(), actual.getLastName()); diff --git a/testsuite/integration/src/test/resources/model/testrealm.json b/testsuite/integration/src/test/resources/model/testrealm.json index 00336c82ea..4512bad503 100755 --- a/testsuite/integration/src/test/resources/model/testrealm.json +++ b/testsuite/integration/src/test/resources/model/testrealm.json @@ -55,6 +55,7 @@ { "username": "wburke", "enabled": true, + "createdTimestamp" : 123654, "attributes": { "email": "bburke@redhat.com" }, @@ -71,6 +72,7 @@ }, { "username": "loginclient", + "createdTimestamp" : "123655", "enabled": true, "credentials": [ {