From 1872d7040ac376b77d3548d5690364199afd93a1 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Wed, 22 Jul 2026 14:48:58 +0200 Subject: [PATCH 01/14] Add entity for API Tokens --- .../java/com/gentics/mesh/ElementType.java | 4 +- .../META-INF/liquibase/changelog-master.xml | 2 + .../1-features/changelog-gpu-2604.xml | 38 ++++ .../mesh/core/data/dao/APITokenDao.java | 48 +++++ .../mesh/core/data/dao/DaoCollection.java | 2 + .../mesh/core/data/user/HibAPITokenData.java | 116 +++++++++++ .../gentics/mesh/dagger/DaoHelperModule.java | 7 + .../gentics/mesh/dagger/HibernateModule.java | 5 + .../gentics/mesh/database/HibernateTx.java | 4 + .../mesh/database/HibernateTxImpl.java | 6 + .../hibernate/data/dao/APITokenDaoImpl.java | 88 +++++++++ .../mesh/hibernate/data/dao/DaoHelper.java | 4 + .../data/dao/HibDaoCollectionImpl.java | 10 +- .../data/domain/HibAPITokenDataImpl.java | 115 +++++++++++ .../main/resources/META-INF/persistence.xml | 1 + .../core/rest/user/UserAPITokenDataModel.java | 10 + .../gentics/mesh/core/user/APITokenTest.java | 183 ++++++++++++++++++ 17 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml create mode 100644 mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java create mode 100644 mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java create mode 100644 mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java create mode 100644 mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java create mode 100644 rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java create mode 100644 tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java diff --git a/api/src/main/java/com/gentics/mesh/ElementType.java b/api/src/main/java/com/gentics/mesh/ElementType.java index f04cd7d0302..44905456dd9 100644 --- a/api/src/main/java/com/gentics/mesh/ElementType.java +++ b/api/src/main/java/com/gentics/mesh/ElementType.java @@ -33,7 +33,9 @@ public enum ElementType { BRANCH, - NODE; + NODE, + + APITOKEN; /** * Parse the string value into the Mesh element type, if possible. diff --git a/connectors/common/src/main/resources/META-INF/liquibase/changelog-master.xml b/connectors/common/src/main/resources/META-INF/liquibase/changelog-master.xml index 156e01d2e4e..d854a653f25 100644 --- a/connectors/common/src/main/resources/META-INF/liquibase/changelog-master.xml +++ b/connectors/common/src/main/resources/META-INF/liquibase/changelog-master.xml @@ -25,4 +25,6 @@ + + diff --git a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml new file mode 100644 index 00000000000..e42765555c2 --- /dev/null +++ b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java new file mode 100644 index 00000000000..15505bc36ac --- /dev/null +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java @@ -0,0 +1,48 @@ +package com.gentics.mesh.core.data.dao; + +import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.page.Page; +import com.gentics.mesh.core.data.user.HibAPITokenData; +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.parameter.PagingParameters; + +/** + * DAO for API Tokens + */ +public interface APITokenDao extends Dao, DaoTransformable { + /** + * Create an API Token + * @param user user (owner) + * @param name name + * @param tokenId token ID + * @param expires optional expiry timestamp + * @return created instance + */ + HibAPITokenData create(HibUser user, String name, String tokenId, Long expires); + + /** + * Find an API Token for a user be UUID + * @param user user + * @param uuid UUID + * @return instance or null + */ + HibAPITokenData findByUuid(HibUser user, String uuid); + + /** + * Find an API Token for a user by Token ID + * @param user user + * @param tokenId Token ID + * @return instance or null + */ + HibAPITokenData findByTokenId(HibUser user, String tokenId); + + /** + * Find all API Tokens for a user + * @param ac action context + * @param user user + * @param pagingInfo optional paging info + * @return page of API Token instances + */ + Page findAll(InternalActionContext ac, HibUser user, PagingParameters pagingInfo); +} diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java index 89457b361e5..259d98b79bb 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java @@ -68,6 +68,8 @@ public interface DaoCollection { ContentDao contentDao(); + APITokenDao apiTokenDao(); + /** * Try looking for a DAO that corresponds to the {@link ElementType}. Not every type is supported though. * diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java new file mode 100644 index 00000000000..cee792216bd --- /dev/null +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java @@ -0,0 +1,116 @@ +package com.gentics.mesh.core.data.user; + +import java.util.Optional; + +import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.HibNamedElement; +import com.gentics.mesh.core.data.HibTransformableElement; +import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.util.DateUtils; + +/** + * Domain model for API Token Data + */ +public interface HibAPITokenData extends HibTransformableElement, HibNamedElement { + /** + * Return the user to which the token belongs + * @return user + */ + HibUser getUser(); + + /** + * Set the user + * @param user user + * @return fluent API + */ + HibAPITokenData setUser(HibUser user); + + /** + * Return the API token id + * @return token id + */ + String getTokenId(); + + /** + * Set the user API token id. + * + * @param tokenId token id + * @return Fluent API + */ + HibAPITokenData setTokenId(String tokenId); + + /** + * Return the timestamp when the api key token code was issued. + * + * @return timestamp + */ + Long getIssuedTimestamp(); + + /** + * Return the API token issue date. + * + * @return ISO8601 formatted date or null if the date has not yet been set + */ + default String getIssuedDate() { + return Optional.ofNullable(getIssuedTimestamp()).map(DateUtils::toISO8601).orElse(null); + } + + /** + * Set the API token issue timestamp to the current time. + * + * @return Fluent API + */ + HibAPITokenData setIssuedTimestamp(); + + /** + * Return the timestamp when the api token was last used + * @return timestamp + */ + Long getLastUsedTimestamp(); + + /** + * Return the date when the token was last used + * @return ISO8601 formatted date or null if the token was not yet used + */ + default String getLastUsedDate() { + return Optional.ofNullable(getLastUsedTimestamp()).map(DateUtils::toISO8601).orElse(null); + } + + /** + * Set the last used timestamp to the current time + * @return fluent API + */ + HibAPITokenData setLastUsedTimestamp(); + + /** + * Return the timestamp when the token will expire (null if it does not expire) + * @return timestamp + */ + Long getExpiresTimestamp(); + + /** + * Return the date when the token will expire + * @return ISO8601 formatted date or null if the token does not expire + */ + default String getExpiresDate() { + return Optional.ofNullable(getExpiresTimestamp()).map(DateUtils::toISO8601).orElse(null); + } + + /** + * Set the timestamp when the token will expired (null if the token shall not expire) + * @param expires timestamp + * @return fluent API + */ + HibAPITokenData setExpiresTimestamp(Long expires); + + /** + * Return whether the api token is valid (not expired) + * @return flag + */ + boolean isValid(); + + @Override + default String getAPIPath(InternalActionContext ac) { + return null; + } +} diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/DaoHelperModule.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/DaoHelperModule.java index 9f9ba3f039a..8f1465bd18a 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/DaoHelperModule.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/DaoHelperModule.java @@ -13,6 +13,7 @@ import com.gentics.mesh.core.data.schema.HibSchemaVersion; import com.gentics.mesh.core.data.tag.HibTag; import com.gentics.mesh.core.data.tagfamily.HibTagFamily; +import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; import com.gentics.mesh.hibernate.data.dao.DaoHelper; import com.gentics.mesh.hibernate.data.dao.DaoHelperFactory; @@ -20,6 +21,7 @@ import com.gentics.mesh.hibernate.data.dao.RootDaoHelperFactory; import com.gentics.mesh.hibernate.data.dao.helpers.CrossTableJoin; import com.gentics.mesh.hibernate.data.dao.helpers.FieldJoin; +import com.gentics.mesh.hibernate.data.domain.HibAPITokenDataImpl; import com.gentics.mesh.hibernate.data.domain.HibBranchImpl; import com.gentics.mesh.hibernate.data.domain.HibGroupImpl; import com.gentics.mesh.hibernate.data.domain.HibJobImpl; @@ -116,6 +118,11 @@ DaoHelper tagFamily(DaoHelperFactory factory) { return factory.create(HibTagFamilyImpl.class); } + @Provides + DaoHelper apiToken(DaoHelperFactory factory) { + return factory.create(HibAPITokenDataImpl.class); + } + @Provides RootDaoHelper nodeRoot(RootDaoHelperFactory factory, DaoHelperFactory daoHelperFactory) { DaoHelper daoHelper = node(daoHelperFactory); diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/HibernateModule.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/HibernateModule.java index 785dd0e446a..38e1ddd3097 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/HibernateModule.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/dagger/HibernateModule.java @@ -52,6 +52,7 @@ import com.gentics.mesh.contentoperation.ContentStorage; import com.gentics.mesh.contentoperation.ContentStorageImpl; import com.gentics.mesh.core.data.binary.Binaries; +import com.gentics.mesh.core.data.dao.APITokenDao; import com.gentics.mesh.core.data.dao.ChangelogDao; import com.gentics.mesh.core.data.dao.DaoCollection; import com.gentics.mesh.core.data.dao.PermissionRoots; @@ -97,6 +98,7 @@ import com.gentics.mesh.etc.config.MeshOptions; import com.gentics.mesh.hibernate.HibernateRootResolver; import com.gentics.mesh.hibernate.data.binary.impl.HibBinariesImpl; +import com.gentics.mesh.hibernate.data.dao.APITokenDaoImpl; import com.gentics.mesh.hibernate.data.dao.BinaryDaoImpl; import com.gentics.mesh.hibernate.data.dao.BranchDaoImpl; import com.gentics.mesh.hibernate.data.dao.ChangelogDaoImpl; @@ -292,6 +294,9 @@ public abstract class HibernateModule { @Binds abstract S3Binaries bindS3Binaries(S3HibBinariesImpl e); + @Binds + abstract APITokenDao bindAPITokenDao(APITokenDaoImpl e); + @Provides public static HibernateMeshOptions hibernateMeshOptions(MeshOptions meshOptions) { if (meshOptions instanceof HibernateMeshOptions) { diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTx.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTx.java index 63b82297bbc..e974944c6e9 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTx.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTx.java @@ -10,6 +10,7 @@ import com.gentics.mesh.core.db.CommonTx; import com.gentics.mesh.core.db.Tx; import com.gentics.mesh.hibernate.ContentInterceptor; +import com.gentics.mesh.hibernate.data.dao.APITokenDaoImpl; import com.gentics.mesh.hibernate.data.dao.BinaryDaoImpl; import com.gentics.mesh.hibernate.data.dao.BranchDaoImpl; import com.gentics.mesh.hibernate.data.dao.ContentDaoImpl; @@ -143,6 +144,9 @@ default ContentInterceptor getContentInterceptor() { @Override S3BinaryDaoImpl s3binaryDao(); + @Override + APITokenDaoImpl apiTokenDao(); + /** * Add the given action to be executed before the transaction is closed. * @param action action diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTxImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTxImpl.java index 302841998ad..bf933f36d18 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTxImpl.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/database/HibernateTxImpl.java @@ -35,6 +35,7 @@ import com.gentics.mesh.core.data.s3binary.S3Binaries; import com.gentics.mesh.core.db.Tx; import com.gentics.mesh.dagger.tx.TransactionScope; +import com.gentics.mesh.hibernate.data.dao.APITokenDaoImpl; import com.gentics.mesh.hibernate.data.dao.BinaryDaoImpl; import com.gentics.mesh.hibernate.data.dao.BranchDaoImpl; import com.gentics.mesh.hibernate.data.dao.ContentDaoImpl; @@ -362,6 +363,11 @@ public ImageVariantDaoImpl imageVariantDao() { return daoCollection.imageVariantDao(); } + @Override + public APITokenDaoImpl apiTokenDao() { + return daoCollection.apiTokenDao(); + } + @Override public PermissionCache permissionCache() { return caches.permissionCache(); diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java new file mode 100644 index 00000000000..5a080a1838c --- /dev/null +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java @@ -0,0 +1,88 @@ +package com.gentics.mesh.hibernate.data.dao; + +import static com.gentics.mesh.hibernate.util.HibernateUtil.firstOrNull; + +import java.util.Objects; +import java.util.Optional; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; +import org.hibernate.jpa.HibernateHints; + +import com.gentics.graphqlfilter.filter.operation.Comparison; +import com.gentics.graphqlfilter.filter.operation.FieldOperand; +import com.gentics.graphqlfilter.filter.operation.FilterOperation; +import com.gentics.graphqlfilter.filter.operation.LiteralOperand; +import com.gentics.mesh.ElementType; +import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.dao.APITokenDao; +import com.gentics.mesh.core.data.page.Page; +import com.gentics.mesh.core.data.user.HibAPITokenData; +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.database.CurrentTransaction; +import com.gentics.mesh.hibernate.data.domain.HibAPITokenDataImpl; +import com.gentics.mesh.parameter.PagingParameters; + +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +/** + * Implementation of {@link APITokenDao} + */ +@Singleton +public class APITokenDaoImpl implements APITokenDao { + protected final CurrentTransaction currentTransaction; + + protected final DaoHelper daoHelper; + + @Inject + public APITokenDaoImpl(CurrentTransaction currentTransaction, DaoHelper daoHelper) { + this.currentTransaction = currentTransaction; + this.daoHelper = daoHelper; + } + + @Override + public UserAPITokenDataModel transformToRestSync(HibAPITokenData element, InternalActionContext ac, int level, + String... languageTags) { + // TODO Auto-generated method stub + return null; + } + + @Override + public HibAPITokenData create(HibUser user, String name, String tokenId, Long expires) { + return currentTransaction.getTx().create(null, HibAPITokenDataImpl.class, tokenData -> { + tokenData.setUser(user); + tokenData.setName(name); + tokenData.setTokenId(tokenId); + tokenData.setIssuedTimestamp(); + tokenData.setExpiresTimestamp(expires); + }); + } + + @Override + public HibAPITokenData findByUuid(HibUser user, String uuid) { + return Optional.ofNullable(daoHelper.findByUuid(uuid)).filter(t -> Objects.equals(t.getUser(), user)) + .orElse(null); + } + + @Override + public HibAPITokenData findByTokenId(HibUser user, String tokenId) { + CriteriaQuery query = daoHelper.cb().createQuery(daoHelper.getDomainClass()); + + Root root = query.from(daoHelper.getDomainClass()); + + query.where(daoHelper.cb().equal(root.get("tokenId"), tokenId)); + return Optional.ofNullable(firstOrNull( + daoHelper.em().createQuery(query).setHint(HibernateHints.HINT_CACHEABLE, true).setMaxResults(1))) + .filter(t -> Objects.equals(t.getUser(), user)).orElse(null); + } + + @Override + public Page findAll(InternalActionContext ac, HibUser user, PagingParameters pagingInfo) { + FilterOperation userFilter = Comparison.eq(new FieldOperand<>(ElementType.APITOKEN, "user_dbUuid"), new LiteralOperand<>(user.getUuid(), false), StringUtils.EMPTY); + return daoHelper.findAll(ac, Optional.empty(), pagingInfo, Optional.of(userFilter)); + } +} diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/DaoHelper.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/DaoHelper.java index fe47a57f5dc..c09003949c1 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/DaoHelper.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/DaoHelper.java @@ -90,6 +90,7 @@ import com.gentics.mesh.hibernate.data.domain.AbstractBinaryImpl; import com.gentics.mesh.hibernate.data.domain.AbstractFieldEdgeImpl; import com.gentics.mesh.hibernate.data.domain.AbstractHibListFieldEdgeImpl; +import com.gentics.mesh.hibernate.data.domain.HibAPITokenDataImpl; import com.gentics.mesh.hibernate.data.domain.HibBinaryFieldEdgeImpl; import com.gentics.mesh.hibernate.data.domain.HibBinaryImpl; import com.gentics.mesh.hibernate.data.domain.HibBooleanListFieldEdgeImpl; @@ -1717,6 +1718,9 @@ private Optional> tableNameIntoClass(String tableName) { case USER: clss = HibUserImpl.class; break; + case APITOKEN: + clss = HibAPITokenDataImpl.class; + break; default: throw new IllegalStateException("FIXME: unexpected element type, that should be supported " + etype); } diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/HibDaoCollectionImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/HibDaoCollectionImpl.java index 740ef34fb7d..8f9eff63693 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/HibDaoCollectionImpl.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/HibDaoCollectionImpl.java @@ -70,6 +70,7 @@ public class HibDaoCollectionImpl implements DaoCollection { private final S3BinaryDaoImpl s3BinaryDao; private final JobDaoImpl jobDao; private final ImageVariantDaoImpl imageVariantDao; + private final APITokenDaoImpl apiTokenDao; @Inject public HibDaoCollectionImpl( @@ -107,7 +108,8 @@ public HibDaoCollectionImpl( BinaryDaoImpl binaryDao, S3BinaryDaoImpl s3BinaryDao, JobDaoImpl jobDao, - ImageVariantDaoImpl imageVariantDao + ImageVariantDaoImpl imageVariantDao, + APITokenDaoImpl apiTokenDao ) { this.userDao = userDao; @@ -145,6 +147,7 @@ public HibDaoCollectionImpl( this.s3BinaryDao = s3BinaryDao; this.jobDao = jobDao; this.imageVariantDao = imageVariantDao; + this.apiTokenDao = apiTokenDao; } @Override @@ -272,6 +275,11 @@ public ImageVariantDaoImpl imageVariantDao() { return imageVariantDao; } + @Override + public APITokenDaoImpl apiTokenDao() { + return apiTokenDao; + } + public Optional> maybeFindDao(Class cls) { return Optional.ofNullable(TypeInfoUtil.getType(cls)).flatMap(this::maybeFindDao).map(AbstractHibDao.class::cast); } diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java new file mode 100644 index 00000000000..0fb98660949 --- /dev/null +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java @@ -0,0 +1,115 @@ +package com.gentics.mesh.hibernate.data.domain; + +import java.time.Instant; +import java.util.Optional; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.user.HibAPITokenData; +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.util.ETag; + +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; + +/** + * Entity implementation of {@link HibAPITokenData} + */ +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "HibEntityCache") +@Entity(name = "apitoken") +public class HibAPITokenDataImpl extends AbstractHibDatabaseElement implements HibAPITokenData { + private String name; + + @ManyToOne(targetEntity = HibUserImpl.class) + private HibUser user; + + private String tokenId; + + private Instant issued; + + private Instant lastUsed; + + private Instant expires; + + @Override + public String getETag(InternalActionContext ac) { + // since only the last used timestamp and the validity can change, we only use tokenId, lastUsed and isValid() to generate the etag + return ETag.hash("%s%s%b".formatted(tokenId, Optional.ofNullable(lastUsed).map(Instant::toString).orElse(""), isValid())); + } + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public HibUser getUser() { + return user; + } + + @Override + public HibAPITokenData setUser(HibUser user) { + this.user = user; + return this; + } + + @Override + public String getTokenId() { + return tokenId; + } + + @Override + public HibAPITokenData setTokenId(String tokenId) { + this.tokenId = tokenId; + return this; + } + + @Override + public Long getIssuedTimestamp() { + return Optional.ofNullable(issued).map(Instant::toEpochMilli).orElse(null); + } + + @Override + public HibAPITokenData setIssuedTimestamp() { + issued = Instant.now(); + return this; + } + + @Override + public Long getLastUsedTimestamp() { + return Optional.ofNullable(lastUsed).map(Instant::toEpochMilli).orElse(null); + } + + @Override + public HibAPITokenData setLastUsedTimestamp() { + lastUsed = Instant.now(); + return this; + } + + @Override + public Long getExpiresTimestamp() { + return Optional.ofNullable(expires).map(Instant::toEpochMilli).orElse(null); + } + + @Override + public HibAPITokenData setExpiresTimestamp(Long expires) { + this.expires = Optional.ofNullable(expires).map(Instant::ofEpochMilli).orElse(null); + return this; + } + + @Override + public boolean isValid() { + if (expires == null) { + return true; + } else { + return expires.isAfter(Instant.now()); + } + } +} diff --git a/mdm/hibernate-core/src/main/resources/META-INF/persistence.xml b/mdm/hibernate-core/src/main/resources/META-INF/persistence.xml index 36b0aee30f3..aeda614275b 100644 --- a/mdm/hibernate-core/src/main/resources/META-INF/persistence.xml +++ b/mdm/hibernate-core/src/main/resources/META-INF/persistence.xml @@ -52,5 +52,6 @@ com.gentics.mesh.hibernate.data.domain.HibUserImpl com.gentics.mesh.hibernate.data.domain.HibVersionPurgeJobImpl com.gentics.mesh.hibernate.data.domain.MeshVersionEntityImpl + com.gentics.mesh.hibernate.data.domain.HibAPITokenDataImpl diff --git a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java new file mode 100644 index 00000000000..52bec8b707f --- /dev/null +++ b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java @@ -0,0 +1,10 @@ +package com.gentics.mesh.core.rest.user; + +import com.gentics.mesh.core.rest.common.AbstractResponse; + +/** + * REST Model for API Tokens + */ +public class UserAPITokenDataModel extends AbstractResponse { + +} diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java new file mode 100644 index 00000000000..15d6d28b916 --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java @@ -0,0 +1,183 @@ +package com.gentics.mesh.core.user; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.Test; + +import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.dao.APITokenDao; +import com.gentics.mesh.core.data.page.Page; +import com.gentics.mesh.core.data.user.HibAPITokenData; +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.parameter.impl.PagingParametersImpl; +import com.gentics.mesh.test.MeshTestSetting; +import com.gentics.mesh.test.TestSize; +import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.util.TokenUtil; + +/** + * Test cases for handling of {@link HibAPITokenData} instances + */ +@MeshTestSetting(testSize = TestSize.PROJECT, startServer = false) +public class APITokenTest extends AbstractMeshTest { + /** + * Test creation + */ + @Test + public void testCreate() { + String tokenId = TokenUtil.randomToken(); + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), "Test Token", tokenId, null); + }); + assertThat(tokenData).as("Created API Token") + .isNotNull() + .hasFieldOrProperty("uuid") + .hasFieldOrPropertyWithValue("name", "Test Token") + .hasFieldOrPropertyWithValue("user.username", "joe1") + .hasFieldOrPropertyWithValue("tokenId", tokenId) + .hasFieldOrPropertyWithValue("expiresTimestamp", null) + .hasFieldOrPropertyWithValue("lastUsed", null) + .hasFieldOrPropertyWithValue("valid", true); + } + + /** + * Test creating an expired token + */ + @Test + public void testCreatedExpired() { + String tokenId = TokenUtil.randomToken(); + Instant expires = Instant.now().minus(1, ChronoUnit.MINUTES); + + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), "Expired Token", tokenId, expires.toEpochMilli()); + }); + + assertThat(tokenData).as("Created API Token") + .isNotNull() + .hasFieldOrPropertyWithValue("expiresTimestamp", expires.toEpochMilli()) + .hasFieldOrPropertyWithValue("lastUsed", null) + .hasFieldOrPropertyWithValue("valid", false); + } + + /** + * Test creating a token that will expired + */ + @Test + public void testCreatedWillExpire() { + String tokenId = TokenUtil.randomToken(); + Instant expires = Instant.now().plus(1, ChronoUnit.MINUTES); + + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), "Expired Token", tokenId, expires.toEpochMilli()); + }); + + assertThat(tokenData).as("Created API Token") + .isNotNull() + .hasFieldOrPropertyWithValue("expiresTimestamp", expires.toEpochMilli()) + .hasFieldOrPropertyWithValue("lastUsed", null) + .hasFieldOrPropertyWithValue("valid", true); + } + + /** + * Test loading by uuid + */ + @Test + public void testLoadByUuid() { + String tokenId = TokenUtil.randomToken(); + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), "Test Token", tokenId, null); + }); + + HibAPITokenData reloadedTokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.findByUuid(user(), tokenData.getUuid()); + }); + assertThat(reloadedTokenData).as("Reloaded API Token") + .isNotNull() + .isEqualTo(tokenData); + + tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + Map users = new HashMap<>(users()); + users.remove(user().getUsername()); + assertThat(users).as("Map of other users") + .isNotEmpty() + .doesNotContainValue(user()); + + for (HibUser user : users.values()) { + assertThat(apiTokenDao.findByUuid(user, tokenData.getUuid())).as("Token fetched for other user").isNull(); + } + }); + } + + /** + * Test loading by tokenId + */ + @Test + public void testLoadByTokenId() { + String tokenId = TokenUtil.randomToken(); + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), "Test Token", tokenId, null); + }); + + HibAPITokenData reloadedTokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.findByTokenId(user(), tokenId); + }); + assertThat(reloadedTokenData).as("Reloaded API Token") + .isNotNull() + .isEqualTo(tokenData); + + tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + Map users = new HashMap<>(users()); + users.remove(user().getUsername()); + assertThat(users).as("Map of other users") + .isNotEmpty() + .doesNotContainValue(user()); + + for (HibUser user : users.values()) { + assertThat(apiTokenDao.findByTokenId(user, tokenId)).as("Token fetched for other user").isNull(); + } + }); + } + + /** + * Test loading the list of API Tokens for a user + */ + @Test + public void testList() { + int numTokens = 100; + Set tokens = new HashSet<>(); + for (int i = 0; i < numTokens; i++) { + String name = "Test Token #%d".formatted(i); + tokens.add(tx(tx -> { + return tx.apiTokenDao().create(user(), name, TokenUtil.randomToken(), null); + })); + + String foreignName = "Foreign Test Token #%d".formatted(i); + tx(tx -> { + tx.apiTokenDao().create(users().get("admin"), foreignName, TokenUtil.randomToken(), null); + }); + } + + Page list = tx(tx -> { + InternalActionContext ac = mockActionContext(); + return tx.apiTokenDao().findAll(ac, user(), new PagingParametersImpl(1, 1000L)); + }); + + assertThat(list).as("List of API Tokens").hasSameElementsAs(tokens); + } +} From 9d47143fc0e0c8d236973c2f95ee17c19765b6e4 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Thu, 23 Jul 2026 09:55:50 +0200 Subject: [PATCH 02/14] Implement endpoint to create API Token --- .../auth/provider/MeshJWTAuthProvider.java | 4 +- .../resources/i18n/translations_de.properties | 4 + .../resources/i18n/translations_en.properties | 4 + .../1-features/changelog-gpu-2604.xml | 7 +- .../core/endpoint/user/UserCrudHandler.java | 54 ++++++--- .../mesh/core/endpoint/user/UserEndpoint.java | 4 +- .../mesh/core/data/dao/APITokenDao.java | 10 +- .../gentics/mesh/example/ExampleUuids.java | 2 + .../gentics/mesh/example/UserExamples.java | 28 ++++- .../hibernate/data/dao/APITokenDaoImpl.java | 40 +++++-- .../rest/user/UserAPITokenCreateRequest.java | 60 ++++++++++ .../core/rest/user/UserAPITokenDataModel.java | 110 ++++++++++++++++++ .../core/rest/user/UserAPITokenResponse.java | 26 ++--- .../gentics/mesh/core/user/APITokenTest.java | 89 +++++++++++--- .../mesh/core/user/UserEndpointTest.java | 4 +- 15 files changed, 380 insertions(+), 66 deletions(-) create mode 100644 rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenCreateRequest.java diff --git a/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java b/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java index 04e3299e43c..ffafcb78d9d 100644 --- a/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java +++ b/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java @@ -230,7 +230,7 @@ public String generateToken(User user) { * @param user * @param tokenCode * Code which will be part of the JWT. This code is used to verify that the JWT is still valid - * @param expireDuration + * @param expireDuration expire duration in seconds * @return Generated API key */ public String generateAPIToken(HibUser user, String tokenCode, Integer expireDuration) { @@ -240,7 +240,7 @@ public String generateAPIToken(HibUser user, String tokenCode, Integer expireDur .put(API_KEY_TOKEN_CODE_FIELD_NAME, tokenCode); JWTOptions jwtOptions = new JWTOptions().setAlgorithm(options.getAlgorithm()); if (expireDuration != null) { - jwtOptions.setExpiresInMinutes(expireDuration); + jwtOptions.setExpiresInSeconds(expireDuration); } return jwtProvider.generateToken(tokenData, jwtOptions); } diff --git a/common/src/main/resources/i18n/translations_de.properties b/common/src/main/resources/i18n/translations_de.properties index a3e4950c557..1084ba6fcf1 100644 --- a/common/src/main/resources/i18n/translations_de.properties +++ b/common/src/main/resources/i18n/translations_de.properties @@ -118,6 +118,10 @@ user_error_password_check_failed=Das angegebene Passwort stimmt nicht mit dem ak user_error_provided_token_invalid=Der angegebene Token ist ungültig. user_error_admin_privilege_needed_for_admin_flag=Die Admin Berechtigung ist notwendig um das Admin Feld zu setzen. +apitoken_conflicting_name=Es gibt bereits einen API Token mit diesem Namen. +apitoken_missing_name=Es wurde kein Name angegeben. +apitoken_expires_in_past=Für den Ablauf des API Tokens wurde ein Zeitpunkt in der Vergangenheit angegeben. + role_deleted=Rolle "{0}" wurde gelöscht. role_not_found=Rolle mit uuid "{0}" konnte nicht gefunden werden. role_conflicting_name=Rollenname bereits belegt. diff --git a/common/src/main/resources/i18n/translations_en.properties b/common/src/main/resources/i18n/translations_en.properties index 784e3f577d3..1eace2b19a4 100644 --- a/common/src/main/resources/i18n/translations_en.properties +++ b/common/src/main/resources/i18n/translations_en.properties @@ -117,6 +117,10 @@ user_error_password_check_failed=The provided old password did not match up with user_error_provided_token_invalid=The provided token is invalid. user_error_admin_privilege_needed_for_admin_flag=The admin privilege is needed to set the admin flag. +apitoken_conflicting_name=API Token with this name already exists. +apitoken_missing_name=No name was specified. +apitoken_expires_in_past=A date in the past was specified for the expiration. + role_deleted=Role "{0}" was deleted. role_not_found=Role with uuid "{0}" could not be found. role_conflicting_name=Role name is conflicting with an existing role. diff --git a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml index e42765555c2..f3c9986f26c 100644 --- a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml +++ b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml @@ -30,9 +30,14 @@ - + + + + + + diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java index 97905d35b6a..c904bc795a5 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java @@ -5,24 +5,35 @@ import static com.gentics.mesh.core.data.perm.InternalPermission.UPDATE_PERM; import static com.gentics.mesh.core.rest.error.Errors.error; import static com.gentics.mesh.rest.Messages.message; +import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST; import static io.netty.handler.codec.http.HttpResponseStatus.CREATED; import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND; import static io.netty.handler.codec.http.HttpResponseStatus.OK; +import java.time.Instant; +import java.util.Optional; + import javax.inject.Inject; import javax.inject.Singleton; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.gentics.mesh.auth.provider.MeshJWTAuthProvider; import com.gentics.mesh.cli.BootstrapInitializer; import com.gentics.mesh.context.InternalActionContext; import com.gentics.mesh.core.action.UserDAOActions; import com.gentics.mesh.core.data.HibBaseElement; +import com.gentics.mesh.core.data.dao.APITokenDao; import com.gentics.mesh.core.data.dao.UserDao; import com.gentics.mesh.core.data.perm.InternalPermission; +import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; import com.gentics.mesh.core.db.Database; import com.gentics.mesh.core.endpoint.handler.AbstractCrudHandler; import com.gentics.mesh.core.rest.common.GenericMessageResponse; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserPermissionResponse; import com.gentics.mesh.core.rest.user.UserResetTokenResponse; @@ -32,9 +43,6 @@ import com.gentics.mesh.util.DateUtils; import com.gentics.mesh.util.TokenUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - /** * Handler which contains methods for user related requests. */ @@ -139,26 +147,38 @@ public void handleFetchToken(InternalActionContext ac, String userUuid) { */ public void handleIssueAPIToken(InternalActionContext ac, String userUuid) { validateParameter(userUuid, "The userUuid must not be empty"); + UserAPITokenCreateRequest request = ac.fromJson(UserAPITokenCreateRequest.class); + // Check for completeness of request + if (StringUtils.isEmpty(request.getName())) { + throw error(BAD_REQUEST, "apitoken_missing_name"); + } try (WriteLock lock = writeLock.lock(ac)) { utils.syncTx(ac, tx -> { // 1. Load the user that should be used HibUser user = tx.userDao().loadObjectByUuid(ac, userUuid, UPDATE_PERM); - // 2. Generate the API key for the user - UserAPITokenResponse apiKeyRespose = db.tx(() -> { - String tokenId = TokenUtil.randomToken(); - String apiToken = authProvider.generateAPIToken(user, tokenId, null); - UserAPITokenResponse response = new UserAPITokenResponse(); - response.setPreviousIssueDate(user.getAPITokenIssueDate()); - - // 3. Issue a new token and update the issue timestamp - user.setAPITokenId(tokenId); - user.setAPITokenIssueTimestamp(); - response.setToken(apiToken); - return response; - }); - return apiKeyRespose; + // 2. Generate the API token for the user + Integer expiresInSeconds = null; + Instant expires = null; + if (!StringUtils.isEmpty(request.getExpires())) { + expires = Instant.ofEpochMilli(DateUtils.fromISO8601(request.getExpires(), true)); + if (expires.isBefore(Instant.now())) { + throw error(BAD_REQUEST, "apitoken_expires_in_past"); + } + expiresInSeconds = (int)(expires.getEpochSecond() - Instant.now().getEpochSecond()); + } + + String tokenId = TokenUtil.randomToken(); + String apiToken = authProvider.generateAPIToken(user, tokenId, expiresInSeconds); + + APITokenDao apiTokenDao = tx.apiTokenDao(); + HibAPITokenData tokenData = apiTokenDao.create(user, request.getName(), tokenId, + Optional.ofNullable(expires).map(Instant::toEpochMilli).orElse(null)); + + return new UserAPITokenResponse() + .setToken(apiToken) + .setData(apiTokenDao.transformToRestSync(tokenData, ac, 0)); }, model -> ac.send(model, CREATED)); } } diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java index 583af106056..26143939396 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java @@ -78,8 +78,10 @@ private void addAPITokenHandler() { "Return API token which can be used to authenticate the user. Store the key somewhere save since you won't be able to retrieve it later on. This invalidates all tokens previously issued for this user. Requires UPDATE permission on the user."); endpoint.method(POST); endpoint.setMutating(true); + endpoint.consumes(APPLICATION_JSON); endpoint.produces(APPLICATION_JSON); - endpoint.exampleResponse(OK, userExamples.getAPIKeyResponse(), "The User API token response."); + endpoint.exampleRequest(userExamples.getAPITokenCreateRequest("New API Token")); + endpoint.exampleResponse(OK, userExamples.getAPIKeyResponse("New API Token"), "The User API token response."); endpoint.blockingHandler(rc -> { InternalActionContext ac = wrap(rc); String uuid = ac.getParameter("userUuid"); diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java index 15505bc36ac..e0f7a619e6b 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java @@ -16,7 +16,7 @@ public interface APITokenDao extends Dao, DaoTransformable, DaoTransformable { tokenData.setUser(user); tokenData.setName(name); @@ -70,14 +81,21 @@ public HibAPITokenData findByUuid(HibUser user, String uuid) { @Override public HibAPITokenData findByTokenId(HibUser user, String tokenId) { - CriteriaQuery query = daoHelper.cb().createQuery(daoHelper.getDomainClass()); - - Root root = query.from(daoHelper.getDomainClass()); + TypedQuery query = daoHelper.em() + .createQuery("FROM apitoken t WHERE t.user = :user AND t.tokenId = :tokenId", HibAPITokenData.class); + query.setParameter("user", user); + query.setParameter("tokenId", tokenId); + query.setHint(HibernateHints.HINT_CACHEABLE, true); + return firstOrNull(query); + } - query.where(daoHelper.cb().equal(root.get("tokenId"), tokenId)); - return Optional.ofNullable(firstOrNull( - daoHelper.em().createQuery(query).setHint(HibernateHints.HINT_CACHEABLE, true).setMaxResults(1))) - .filter(t -> Objects.equals(t.getUser(), user)).orElse(null); + @Override + public HibAPITokenData findByName(HibUser user, String name) { + TypedQuery query = daoHelper.em().createQuery("FROM apitoken t WHERE t.user = :user AND t.name = :name", HibAPITokenData.class); + query.setParameter("user", user); + query.setParameter("name", name); + query.setHint(HibernateHints.HINT_CACHEABLE, true); + return firstOrNull(query); } @Override diff --git a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenCreateRequest.java b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenCreateRequest.java new file mode 100644 index 00000000000..aaba1444484 --- /dev/null +++ b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenCreateRequest.java @@ -0,0 +1,60 @@ +package com.gentics.mesh.core.rest.user; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.gentics.mesh.core.rest.common.RestModel; + +/** + * Request to create an API Token + */ +public class UserAPITokenCreateRequest implements RestModel { + @JsonProperty(required = true) + @JsonPropertyDescription("Name of the API Token.") + private String name; + + @JsonProperty(required = false) + @JsonPropertyDescription("ISO8601 formatted expire date string.") + private String expires; + + /** + * Create an empty instance + */ + public UserAPITokenCreateRequest() { + } + + /** + * Return the name + * @return name + */ + public String getName() { + return name; + } + + /** + * Set the name + * @param name name + * @return fluent API + */ + public UserAPITokenCreateRequest setName(String name) { + this.name = name; + return this; + } + + /** + * Get the ISO8601 formatted expire date + * @return expired date or null + */ + public String getExpires() { + return expires; + } + + /** + * Set the ISO8601 formatted expire date + * @param expires date or null + * @return fluent API + */ + public UserAPITokenCreateRequest setExpires(String expires) { + this.expires = expires; + return this; + } +} diff --git a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java index 52bec8b707f..a70eb35235e 100644 --- a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java +++ b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java @@ -1,10 +1,120 @@ package com.gentics.mesh.core.rest.user; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.gentics.mesh.core.rest.common.AbstractResponse; /** * REST Model for API Tokens */ public class UserAPITokenDataModel extends AbstractResponse { + @JsonProperty(required = true) + @JsonPropertyDescription("Name of the Token.") + private String name; + @JsonProperty(required = true) + @JsonPropertyDescription("ISO8601 formatted issued date string.") + private String issued; + + @JsonProperty(required = false) + @JsonPropertyDescription("ISO8601 formatted last used date string.") + private String lastUsed; + + @JsonProperty(required = false) + @JsonPropertyDescription("ISO8601 formatted expire date string.") + private String expires; + + @JsonProperty(required = true) + @JsonPropertyDescription("True when the token is valid (not expired), false if not.") + private boolean valid; + + /** + * Return the name + * @return name + */ + public String getName() { + return name; + } + + /** + * Set the name + * @param name name + * @return fluent API + */ + public UserAPITokenDataModel setName(String name) { + this.name = name; + return this; + } + + /** + * Get the ISO8601 formatted issued date + * @return date + */ + public String getIssued() { + return issued; + } + + /** + * Set the ISO8601 formatted issued date + * @param issued date + * @return fluent API + */ + public UserAPITokenDataModel setIssued(String issued) { + this.issued = issued; + return this; + } + + /** + * Get the ISO8601 formatted last used date or null + * @return date + */ + public String getLastUsed() { + return lastUsed; + } + + /** + * Set the ISO8601 formatted last sued date + * @param lastUsed date + * @return fluent API + */ + public UserAPITokenDataModel setLastUsed(String lastUsed) { + this.lastUsed = lastUsed; + return this; + } + + /** + * Get the ISO8601 formatted expire date or null + * @return date + */ + public String getExpires() { + return expires; + } + + /** + * Set the ISO8601 formatted expire date + * @param expires date + * @return fluent API + */ + public UserAPITokenDataModel setExpires(String expires) { + this.expires = expires; + return this; + } + + /** + * Get whether the token is valid (not expired) + * @return flag + */ + public boolean isValid() { + return valid; + } + + /** + * Set the valid flag + * @param valid flat + * @return fluent API + */ + public UserAPITokenDataModel setValid(boolean valid) { + this.valid = valid; + return this; + } } diff --git a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenResponse.java b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenResponse.java index 3a03cdc5905..39a290ea090 100644 --- a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenResponse.java +++ b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenResponse.java @@ -14,8 +14,8 @@ public class UserAPITokenResponse implements RestModel { private String token; @JsonProperty(required = true) - @JsonPropertyDescription("Date of the last time the API token was issued.") - private String previousIssueDate; + @JsonPropertyDescription("Data of the created API token.") + private UserAPITokenDataModel data; public UserAPITokenResponse() { } @@ -41,24 +41,20 @@ public UserAPITokenResponse setToken(String token) { } /** - * Returns the date the API token was last issued. - * - * @return + * Return the API token data + * @return data */ - public String getPreviousIssueDate() { - return previousIssueDate; + public UserAPITokenDataModel getData() { + return data; } /** - * Set the issue date. - * - * @param previousIssueDate - * @return Fluent API + * Set the API token data + * @param data token data + * @return fluent API */ - - public UserAPITokenResponse setPreviousIssueDate(String previousIssueDate) { - this.previousIssueDate = previousIssueDate; + public UserAPITokenResponse setData(UserAPITokenDataModel data) { + this.data = data; return this; } - } diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java index 15d6d28b916..7be392ba875 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -16,6 +17,7 @@ import com.gentics.mesh.core.data.page.Page; import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.core.rest.error.NameConflictException; import com.gentics.mesh.parameter.impl.PagingParametersImpl; import com.gentics.mesh.test.MeshTestSetting; import com.gentics.mesh.test.TestSize; @@ -48,6 +50,36 @@ public void testCreate() { .hasFieldOrPropertyWithValue("valid", true); } + /** + * Test creating a token with duplicate name + */ + @Test(expected = NameConflictException.class) + public void testCreateDuplicateName() { + String name = "Conflicting Name"; + tx(tx -> { + tx.apiTokenDao().create(user(), name, TokenUtil.randomToken(), null); + }); + + tx(tx -> { + tx.apiTokenDao().create(user(), name, TokenUtil.randomToken(), null); + }); + } + + /** + * Test creating a token with duplicate name for another user + */ + @Test + public void testCreateDuplicateNameOtherUser() { + String name = "Conflicting Name"; + tx(tx -> { + tx.apiTokenDao().create(user(), name, TokenUtil.randomToken(), null); + }); + + tx(tx -> { + tx.apiTokenDao().create(users().get("admin"), name, TokenUtil.randomToken(), null); + }); + } + /** * Test creating an expired token */ @@ -109,13 +141,7 @@ public void testLoadByUuid() { tx(tx -> { APITokenDao apiTokenDao = tx.apiTokenDao(); - Map users = new HashMap<>(users()); - users.remove(user().getUsername()); - assertThat(users).as("Map of other users") - .isNotEmpty() - .doesNotContainValue(user()); - - for (HibUser user : users.values()) { + for (HibUser user : otherUsers()) { assertThat(apiTokenDao.findByUuid(user, tokenData.getUuid())).as("Token fetched for other user").isNull(); } }); @@ -142,18 +168,40 @@ public void testLoadByTokenId() { tx(tx -> { APITokenDao apiTokenDao = tx.apiTokenDao(); - Map users = new HashMap<>(users()); - users.remove(user().getUsername()); - assertThat(users).as("Map of other users") - .isNotEmpty() - .doesNotContainValue(user()); - - for (HibUser user : users.values()) { + for (HibUser user : otherUsers()) { assertThat(apiTokenDao.findByTokenId(user, tokenId)).as("Token fetched for other user").isNull(); } }); } + /** + * Test loading by name + */ + @Test + public void testLoadByName() { + String tokenId = TokenUtil.randomToken(); + String name = "Test Token"; + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), name, tokenId, null); + }); + + HibAPITokenData reloadedTokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.findByName(user(), name); + }); + assertThat(reloadedTokenData).as("Reloaded API Token") + .isNotNull() + .isEqualTo(tokenData); + + tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + for (HibUser user : otherUsers()) { + assertThat(apiTokenDao.findByName(user, name)).as("Token fetched for other user").isNull(); + } + }); + } + /** * Test loading the list of API Tokens for a user */ @@ -180,4 +228,17 @@ public void testList() { assertThat(list).as("List of API Tokens").hasSameElementsAs(tokens); } + + /** + * Get the collection of other users + * @return collection of users + */ + protected Collection otherUsers() { + Map users = new HashMap<>(users()); + users.remove(user().getUsername()); + assertThat(users).as("Map of other users") + .isNotEmpty() + .doesNotContainValue(user()); + return users.values(); + } } diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java index 2e991e2854f..2f190ed2fce 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java @@ -301,7 +301,7 @@ public void testAPIToken() { assertThat(completeResponse.getHeader("Cache-Control")).hasValue("private"); UserAPITokenResponse response = completeResponse.getBody(); - assertNull("The key was previously not issued.", response.getPreviousIssueDate()); +// assertNull("The key was previously not issued.", response.getPreviousIssueDate()); assertThat(response.getToken()).isNotEmpty(); assertNotNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPIKeyTokenCode(); })); @@ -317,7 +317,7 @@ public void testAPIToken() { String oldKey = response.getToken(); response = call(() -> client().issueAPIToken(uuid)); assertNotEquals("Each key should be unique.", oldKey, response.getToken()); - assertNotNull("The key was already requested once. Thus the date should be set.", response.getPreviousIssueDate()); +// assertNotNull("The key was already requested once. Thus the date should be set.", response.getPreviousIssueDate()); // And continue invoking requests call(() -> client().findUserByUuid(uuid), UNAUTHORIZED, "error_not_authorized"); From 752048d36965ac5e40ee921b235fbf2f591da0e9 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Thu, 23 Jul 2026 11:29:35 +0200 Subject: [PATCH 03/14] Modify delete API Token endpoint --- .../resources/i18n/translations_de.properties | 3 +-- .../resources/i18n/translations_en.properties | 1 + .../resources/i18n/translations_zh.properties | 2 -- .../core/endpoint/user/UserCrudHandler.java | 27 ++++++++++--------- .../mesh/core/endpoint/user/UserEndpoint.java | 11 +++++--- .../mesh/core/data/dao/APITokenDao.java | 6 +++++ .../hibernate/data/dao/APITokenDaoImpl.java | 5 ++++ .../gentics/mesh/core/user/APITokenTest.java | 22 +++++++++++++++ 8 files changed, 57 insertions(+), 20 deletions(-) diff --git a/common/src/main/resources/i18n/translations_de.properties b/common/src/main/resources/i18n/translations_de.properties index 1084ba6fcf1..9475887ea92 100644 --- a/common/src/main/resources/i18n/translations_de.properties +++ b/common/src/main/resources/i18n/translations_de.properties @@ -121,6 +121,7 @@ user_error_admin_privilege_needed_for_admin_flag=Die Admin Berechtigung ist notw apitoken_conflicting_name=Es gibt bereits einen API Token mit diesem Namen. apitoken_missing_name=Es wurde kein Name angegeben. apitoken_expires_in_past=Für den Ablauf des API Tokens wurde ein Zeitpunkt in der Vergangenheit angegeben. +apitoken_deleted=Der API Token wurde entfernt. role_deleted=Rolle "{0}" wurde gelöscht. role_not_found=Rolle mit uuid "{0}" konnte nicht gefunden werden. @@ -320,8 +321,6 @@ graphql_error_missing_perm=Nicht genügend Berechtigungen für Objekt "{1}" vom error_backup=Es konnte kein gültiges Backup im Backup Ordner {0} gefunden werden. -api_key_invalidated=Der zur Zeit aktive API Key wurde ungültig gemacht. - job_error_invalid_state=Der Job {0} kann nicht gelöscht werden weil er bisher noch nicht fehlgeschlagen ist. job_processing_invoked=Die Verarbeitung der Jobs wurde angestoßen. diff --git a/common/src/main/resources/i18n/translations_en.properties b/common/src/main/resources/i18n/translations_en.properties index 1eace2b19a4..325c27ecd3e 100644 --- a/common/src/main/resources/i18n/translations_en.properties +++ b/common/src/main/resources/i18n/translations_en.properties @@ -120,6 +120,7 @@ user_error_admin_privilege_needed_for_admin_flag=The admin privilege is needed t apitoken_conflicting_name=API Token with this name already exists. apitoken_missing_name=No name was specified. apitoken_expires_in_past=A date in the past was specified for the expiration. +apitoken_deleted=The API Token has been removed. role_deleted=Role "{0}" was deleted. role_not_found=Role with uuid "{0}" could not be found. diff --git a/common/src/main/resources/i18n/translations_zh.properties b/common/src/main/resources/i18n/translations_zh.properties index a78c3f5a217..df48f83b8b1 100644 --- a/common/src/main/resources/i18n/translations_zh.properties +++ b/common/src/main/resources/i18n/translations_zh.properties @@ -304,8 +304,6 @@ graphql_error_missing_perm=对类型为“{1}”的对象“{0}”缺少权限 error_backup=在备份位置{0}中找不到有效的备份文件。 -api_key_invalidated=当前活动的API密钥已失效。 - job_error_invalid_state=作业{0}未处于错误状态,因此无法删除。只能删除以前失败的作业。 job_processing_invoked=作业处理已被调用。 diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java index c904bc795a5..3e2caed6d5c 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java @@ -32,7 +32,6 @@ import com.gentics.mesh.core.data.user.HibUser; import com.gentics.mesh.core.db.Database; import com.gentics.mesh.core.endpoint.handler.AbstractCrudHandler; -import com.gentics.mesh.core.rest.common.GenericMessageResponse; import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserPermissionResponse; @@ -186,23 +185,27 @@ public void handleIssueAPIToken(InternalActionContext ac, String userUuid) { /** * Delete the stored API key token code in order to invalidate the API key. * - * @param ac - * @param userUuid + * @param ac action context + * @param userUuid user uuid + * @param tokenUuid token uuid */ - public void handleDeleteAPIToken(InternalActionContext ac, String userUuid) { + public void handleDeleteAPIToken(InternalActionContext ac, String userUuid, String tokenUuid) { validateParameter(userUuid, "The userUuid must not be empty"); + validateParameter(tokenUuid, "The tokenUuid must not be empty"); try (WriteLock lock = writeLock.lock(ac)) { utils.syncTx(ac, tx -> { - // 1. Load the user that should be used - HibUser user = tx.userDao().loadObjectByUuid(ac, userUuid, UPDATE_PERM); + UserDao userDao = tx.userDao(); + APITokenDao apiTokenDao = tx.apiTokenDao(); - // 2. Generate the API key for the user - GenericMessageResponse message = db.tx(() -> { - user.resetAPIToken(); - return message(ac, "api_key_invalidated"); - }); - return message; + HibUser user = userDao.loadObjectByUuid(ac, userUuid, UPDATE_PERM); + HibAPITokenData tokenData = apiTokenDao.findByUuid(user, tokenUuid); + if (tokenData == null) { + throw error(NOT_FOUND, "object_not_found_for_uuid", tokenUuid); + } + apiTokenDao.delete(tokenData); + + return message(ac, "apitoken_deleted"); }, model -> ac.send(model, CREATED)); } } diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java index 26143939396..8eff9d3052e 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java @@ -4,6 +4,7 @@ import static com.gentics.mesh.core.rest.MeshEvent.USER_DELETED; import static com.gentics.mesh.core.rest.MeshEvent.USER_UPDATED; import static com.gentics.mesh.example.ExampleUuids.USER_EDITOR_UUID; +import static com.gentics.mesh.example.ExampleUuids.TOKEN_UUID; import static com.gentics.mesh.http.HttpConstants.APPLICATION_JSON; import static io.netty.handler.codec.http.HttpResponseStatus.CREATED; import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT; @@ -90,9 +91,10 @@ private void addAPITokenHandler() { }, isOrderedBlockingHandlers()); InternalEndpointRoute deleteEndpoint = createRoute(); - deleteEndpoint.path("/:userUuid/token"); - deleteEndpoint.setRAMLPath("/{userUuid}/token"); + deleteEndpoint.path("/:userUuid/token/:tokenUuid"); + deleteEndpoint.setRAMLPath("/{userUuid}/token/{tokenUuid}"); deleteEndpoint.addUriParameter("userUuid", "Uuid of the user.", USER_EDITOR_UUID); + deleteEndpoint.addUriParameter("tokenUuid", "Uuid of the token.", TOKEN_UUID); deleteEndpoint.description("Invalidate the issued API token."); deleteEndpoint.method(DELETE); deleteEndpoint.setMutating(true); @@ -100,8 +102,9 @@ private void addAPITokenHandler() { deleteEndpoint.exampleResponse(OK, miscExamples.createMessageResponse(), "Message confirming the invalidation of the API token. Requires DELETE permission on the user."); deleteEndpoint.blockingHandler(rc -> { InternalActionContext ac = wrap(rc); - String uuid = ac.getParameter("userUuid"); - crudHandler.handleDeleteAPIToken(ac, uuid); + String userUuid = ac.getParameter("userUuid"); + String tokenUuid = ac.getParameter("tokenUuid"); + crudHandler.handleDeleteAPIToken(ac, userUuid, tokenUuid); }, isOrderedBlockingHandlers()); } diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java index e0f7a619e6b..88d487fa00f 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java @@ -53,4 +53,10 @@ public interface APITokenDao extends Dao, DaoTransformable findAll(InternalActionContext ac, HibUser user, PagingParameters pagingInfo); + + /** + * Delete the token + * @param token token + */ + void delete(HibAPITokenData token); } diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java index 2f776e2c0d1..9d7dcf4dca5 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java @@ -103,4 +103,9 @@ public Page findAll(InternalActionContext ac, HibUser FilterOperation userFilter = Comparison.eq(new FieldOperand<>(ElementType.APITOKEN, "user_dbUuid"), new LiteralOperand<>(user.getUuid(), false), StringUtils.EMPTY); return daoHelper.findAll(ac, Optional.empty(), pagingInfo, Optional.of(userFilter)); } + + @Override + public void delete(HibAPITokenData token) { + currentTransaction.getTx().delete(token); + } } diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java index 7be392ba875..53387946b5b 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java @@ -120,6 +120,28 @@ public void testCreatedWillExpire() { .hasFieldOrPropertyWithValue("valid", true); } + /** + * Test deleting a token + */ + @Test + public void testDelete() { + HibAPITokenData tokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.create(user(), "Expired Token", TokenUtil.randomToken(), null); + }); + + tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + apiTokenDao.delete(tokenData); + }); + + HibAPITokenData reloadedTokenData = tx(tx -> { + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.findByUuid(user(), tokenData.getUuid()); + }); + assertThat(reloadedTokenData).as("Deleted token").isNull(); + } + /** * Test loading by uuid */ From 3fafa3fa3867e8072998736277ab13adc00228a3 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Fri, 24 Jul 2026 08:40:56 +0200 Subject: [PATCH 04/14] Add endpoint for listing API Tokens --- .../core/endpoint/user/UserCrudHandler.java | 31 ++++++++++++++++++- .../mesh/core/endpoint/user/UserEndpoint.java | 18 ++++++++++- .../dagger/module/DaoTransformableModule.java | 6 ++++ .../mesh/core/data/dao/DaoCollection.java | 3 ++ .../mesh/core/data/user/HibAPITokenData.java | 18 +++++++++-- .../hibernate/data/dao/APITokenDaoImpl.java | 16 ++++++++-- .../data/domain/HibAPITokenDataImpl.java | 3 ++ 7 files changed, 89 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java index 3e2caed6d5c..f35c37422a7 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserCrudHandler.java @@ -27,6 +27,8 @@ import com.gentics.mesh.core.data.HibBaseElement; import com.gentics.mesh.core.data.dao.APITokenDao; import com.gentics.mesh.core.data.dao.UserDao; +import com.gentics.mesh.core.data.page.Page; +import com.gentics.mesh.core.data.page.PageTransformer; import com.gentics.mesh.core.data.perm.InternalPermission; import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; @@ -39,8 +41,10 @@ import com.gentics.mesh.core.rest.user.UserResponse; import com.gentics.mesh.core.verticle.handler.HandlerUtilities; import com.gentics.mesh.core.verticle.handler.WriteLock; +import com.gentics.mesh.parameter.PagingParameters; import com.gentics.mesh.util.DateUtils; import com.gentics.mesh.util.TokenUtil; +import com.gentics.mesh.util.ValidationUtil; /** * Handler which contains methods for user related requests. @@ -54,11 +58,16 @@ public class UserCrudHandler extends AbstractCrudHandler private MeshJWTAuthProvider authProvider; + private final PageTransformer pageTransformer; + @Inject - public UserCrudHandler(Database db, BootstrapInitializer boot, HandlerUtilities utils, MeshJWTAuthProvider authProvider, WriteLock writeLock, UserDAOActions userActions) { + public UserCrudHandler(Database db, BootstrapInitializer boot, HandlerUtilities utils, + MeshJWTAuthProvider authProvider, WriteLock writeLock, UserDAOActions userActions, + PageTransformer pageTransformer) { super(db, utils, writeLock, userActions); this.boot = boot; this.authProvider = authProvider; + this.pageTransformer = pageTransformer; } /** @@ -210,4 +219,24 @@ public void handleDeleteAPIToken(InternalActionContext ac, String userUuid, Stri } } + /** + * List the API Tokens of the user + * @param ac action context + * @param userUuid user uuid + */ + public void handleListAPITokens(InternalActionContext ac, String userUuid) { + validateParameter(userUuid, "The userUuid must not be empty"); + PagingParameters pagingInfo = ac.getPagingParameters(); + ValidationUtil.validate(pagingInfo); + + utils.syncTx(ac, tx -> { + UserDao userDao = tx.userDao(); + APITokenDao apiTokenDao = tx.apiTokenDao(); + + HibUser user = userDao.loadObjectByUuid(ac, userUuid, UPDATE_PERM); + Page page = apiTokenDao.findAll(ac, user, pagingInfo); + + return pageTransformer.transformToRestSync(page, ac, 0); + }, model -> ac.send(model, OK)); + } } diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java index 8eff9d3052e..5df86f19d1d 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java @@ -99,13 +99,29 @@ private void addAPITokenHandler() { deleteEndpoint.method(DELETE); deleteEndpoint.setMutating(true); deleteEndpoint.produces(APPLICATION_JSON); - deleteEndpoint.exampleResponse(OK, miscExamples.createMessageResponse(), "Message confirming the invalidation of the API token. Requires DELETE permission on the user."); + deleteEndpoint.exampleResponse(OK, miscExamples.createMessageResponse(), "Message confirming the invalidation of the API token. Requires UPDATE permission on the user."); deleteEndpoint.blockingHandler(rc -> { InternalActionContext ac = wrap(rc); String userUuid = ac.getParameter("userUuid"); String tokenUuid = ac.getParameter("tokenUuid"); crudHandler.handleDeleteAPIToken(ac, userUuid, tokenUuid); }, isOrderedBlockingHandlers()); + + InternalEndpointRoute listEndpoint = createRoute(); + listEndpoint.path("/:userUuid/token"); + listEndpoint.setRAMLPath("/{userUuid}/token"); + listEndpoint.addUriParameter("userUuid", "Uuid of the user.", USER_EDITOR_UUID); + listEndpoint.description( + "List tokens of the user. Requires UPDATE permission on the user."); + listEndpoint.method(GET); + listEndpoint.setMutating(false); + listEndpoint.produces(APPLICATION_JSON); + listEndpoint.exampleResponse(OK, null); // FIXME + listEndpoint.blockingHandler(rc -> { + InternalActionContext ac = wrap(rc); + String userUuid = ac.getParameter("userUuid"); + crudHandler.handleListAPITokens(ac, userUuid); + }, false); } private void addReadPermissionHandler() { diff --git a/core/src/main/java/com/gentics/mesh/dagger/module/DaoTransformableModule.java b/core/src/main/java/com/gentics/mesh/dagger/module/DaoTransformableModule.java index ab36ee574d7..86e222dcb94 100644 --- a/core/src/main/java/com/gentics/mesh/dagger/module/DaoTransformableModule.java +++ b/core/src/main/java/com/gentics/mesh/dagger/module/DaoTransformableModule.java @@ -5,6 +5,7 @@ import com.gentics.mesh.ElementType; import com.gentics.mesh.annotation.Getter; import com.gentics.mesh.core.data.HibCoreElement; +import com.gentics.mesh.core.data.dao.APITokenDao; import com.gentics.mesh.core.data.dao.BranchDao; import com.gentics.mesh.core.data.dao.DaoTransformable; import com.gentics.mesh.core.data.dao.GroupDao; @@ -100,4 +101,9 @@ public static Map, ? extends RestModel> language(LanguageDao dao); + + @Binds + @IntoMap + @ElementTypeKey(ElementType.APITOKEN) + abstract DaoTransformable, ? extends RestModel> apiToken(APITokenDao dao); } diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java index 259d98b79bb..ae622a6e867 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/DaoCollection.java @@ -121,6 +121,9 @@ default Optional> maybeFindDao(ElementType etype) { case USER: dao = userDao(); break; + case APITOKEN: + dao = apiTokenDao(); + break; default: break; } diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java index cee792216bd..b51ba3f750e 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java @@ -2,16 +2,30 @@ import java.util.Optional; +import com.gentics.mesh.ElementType; import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.TypeInfo; +import com.gentics.mesh.core.data.HibCoreElement; import com.gentics.mesh.core.data.HibNamedElement; -import com.gentics.mesh.core.data.HibTransformableElement; import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; import com.gentics.mesh.util.DateUtils; /** * Domain model for API Token Data */ -public interface HibAPITokenData extends HibTransformableElement, HibNamedElement { +public interface HibAPITokenData extends HibCoreElement, HibNamedElement { + TypeInfo TYPE_INFO = new TypeInfo(ElementType.APITOKEN, null, null, null); + + @Override + default TypeInfo getTypeInfo() { + return TYPE_INFO; + } + + @Override + default String getSubETag(InternalActionContext ac) { + return null; + } + /** * Return the user to which the token belongs * @return user diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java index 9d7dcf4dca5..f32c641878e 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java @@ -23,23 +23,30 @@ import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.data.dao.util.CommonDaoHelper; import com.gentics.mesh.database.CurrentTransaction; import com.gentics.mesh.hibernate.data.domain.HibAPITokenDataImpl; +import com.gentics.mesh.hibernate.data.permission.HibPermissionRoots; +import com.gentics.mesh.hibernate.event.EventFactory; import com.gentics.mesh.parameter.PagingParameters; +import dagger.Lazy; +import io.vertx.core.Vertx; import jakarta.persistence.TypedQuery; /** * Implementation of {@link APITokenDao} */ @Singleton -public class APITokenDaoImpl implements APITokenDao { +public class APITokenDaoImpl extends AbstractHibDao implements APITokenDao { protected final CurrentTransaction currentTransaction; protected final DaoHelper daoHelper; @Inject - public APITokenDaoImpl(CurrentTransaction currentTransaction, DaoHelper daoHelper) { + public APITokenDaoImpl(CurrentTransaction currentTransaction, DaoHelper daoHelper, HibPermissionRoots permissionRoots, CommonDaoHelper commonDaoHelper, + EventFactory eventFactory, Lazy vertx) { + super(permissionRoots, commonDaoHelper, currentTransaction, eventFactory, vertx); this.currentTransaction = currentTransaction; this.daoHelper = daoHelper; } @@ -108,4 +115,9 @@ public Page findAll(InternalActionContext ac, HibUser public void delete(HibAPITokenData token) { currentTransaction.getTx().delete(token); } + + @Override + public String[] getHibernateEntityName(Object... arg) { + return new String[] {currentTransaction.getTx().data().getDatabaseConnector().maybeGetDatabaseEntityName(HibAPITokenDataImpl.class).get()}; + } } diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java index 0fb98660949..d73239bf310 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java @@ -6,9 +6,11 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import com.gentics.mesh.ElementType; import com.gentics.mesh.context.InternalActionContext; import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.dagger.annotations.ElementTypeKey; import com.gentics.mesh.util.ETag; import jakarta.persistence.Entity; @@ -19,6 +21,7 @@ */ @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "HibEntityCache") @Entity(name = "apitoken") +@ElementTypeKey(ElementType.APITOKEN) public class HibAPITokenDataImpl extends AbstractHibDatabaseElement implements HibAPITokenData { private String name; From 227f9854841037b5e6da66af0ecf74b1b50870b1 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Fri, 24 Jul 2026 09:22:53 +0200 Subject: [PATCH 05/14] Check against stored API Token entities --- .../mesh/auth/provider/MeshJWTAuthProvider.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java b/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java index ffafcb78d9d..a030e2d16e7 100644 --- a/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java +++ b/common/src/main/java/com/gentics/mesh/auth/provider/MeshJWTAuthProvider.java @@ -17,6 +17,7 @@ import com.gentics.mesh.auth.AuthenticationResult; import com.gentics.mesh.cli.BootstrapInitializer; import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; import com.gentics.mesh.core.data.user.MeshAuthUser; import com.gentics.mesh.core.db.Database; @@ -271,15 +272,12 @@ private User loadUserByJWT(JsonObject jwt) throws Exception { // } // Check whether the token might be an API key token - if (!jwt.containsKey("exp")) { - String apiKeyToken = jwt.getString(API_KEY_TOKEN_CODE_FIELD_NAME); - // TODO: All tokens without exp must have a token code - See https://github.com/gentics/mesh/issues/412 - if (apiKeyToken != null) { - String storedApiKey = user.getDelegate().getAPIKeyTokenCode(); - // Verify that the API token is invalid. - if (apiKeyToken != null && !apiKeyToken.equals(storedApiKey)) { - throw new Exception("API key token is invalid."); - } + String apiKeyToken = jwt.getString(API_KEY_TOKEN_CODE_FIELD_NAME); + if (apiKeyToken != null) { + HibAPITokenData tokenData = tx.apiTokenDao().findByTokenId(user.getDelegate(), apiKeyToken); + + if (tokenData == null) { + throw new Exception("API key token is invalid."); } } From ab3b09bbf1c66500621b5a4a8104c4a1dbdd0317 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Fri, 24 Jul 2026 09:48:04 +0200 Subject: [PATCH 06/14] Modify Mesh Client for new API Token Endpoints --- .../mesh/rest/MeshLocalClientImpl.java | 12 ++++++-- .../client/impl/MeshRestHttpClientImpl.java | 18 +++++++++--- .../rest/client/method/UserClientMethods.java | 23 +++++++++++---- .../rest/user/UserAPITokenListResponse.java | 10 +++++++ .../mesh/auth/OAuth2KeycloakPluginTest.java | 3 +- .../mesh/client/MeshRestClientTokenTest.java | 3 +- .../core/user/AuthenticationEndpointTest.java | 3 +- .../mesh/core/user/UserEndpointTest.java | 28 ++++++------------- 8 files changed, 65 insertions(+), 35 deletions(-) create mode 100644 rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenListResponse.java diff --git a/core/src/main/java/com/gentics/mesh/rest/MeshLocalClientImpl.java b/core/src/main/java/com/gentics/mesh/rest/MeshLocalClientImpl.java index 21254dafe72..8d92f867fe7 100644 --- a/core/src/main/java/com/gentics/mesh/rest/MeshLocalClientImpl.java +++ b/core/src/main/java/com/gentics/mesh/rest/MeshLocalClientImpl.java @@ -115,6 +115,8 @@ import com.gentics.mesh.core.rest.tag.TagListUpdateRequest; import com.gentics.mesh.core.rest.tag.TagResponse; import com.gentics.mesh.core.rest.tag.TagUpdateRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenListResponse; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserCreateRequest; import com.gentics.mesh.core.rest.user.UserListResponse; @@ -1658,13 +1660,19 @@ public MeshRequest invokeImport() { } @Override - public MeshRequest issueAPIToken(String userUuid) { + public MeshRequest issueAPIToken(String userUuid, UserAPITokenCreateRequest request) { // TODO Auto-generated method stub return null; } @Override - public MeshRequest invalidateAPIToken(String userUuid) { + public MeshRequest invalidateAPIToken(String userUuid, String tokenUuid) { + // TODO Auto-generated method stub + return null; + } + + @Override + public MeshRequest findAPITokens(String userUuid, ParameterProvider... parameters) { // TODO Auto-generated method stub return null; } diff --git a/rest-client/src/main/java/com/gentics/mesh/rest/client/impl/MeshRestHttpClientImpl.java b/rest-client/src/main/java/com/gentics/mesh/rest/client/impl/MeshRestHttpClientImpl.java index e71cdf1c279..f7c55476624 100644 --- a/rest-client/src/main/java/com/gentics/mesh/rest/client/impl/MeshRestHttpClientImpl.java +++ b/rest-client/src/main/java/com/gentics/mesh/rest/client/impl/MeshRestHttpClientImpl.java @@ -97,6 +97,8 @@ import com.gentics.mesh.core.rest.tag.TagListUpdateRequest; import com.gentics.mesh.core.rest.tag.TagResponse; import com.gentics.mesh.core.rest.tag.TagUpdateRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenListResponse; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserCreateRequest; import com.gentics.mesh.core.rest.user.UserListResponse; @@ -621,15 +623,23 @@ public MeshRequest getUserResetToken(String userUuid) { } @Override - public MeshRequest issueAPIToken(String userUuid) { + public MeshRequest issueAPIToken(String userUuid, UserAPITokenCreateRequest userAPITokenCreateRequest) { Util.requireUuid(userUuid, "userUuid"); - return prepareRequest(POST, "/users/" + userUuid + "/token", UserAPITokenResponse.class); + Objects.requireNonNull(userAPITokenCreateRequest, "userAPITokenCreateRequest must not be null"); + return prepareRequest(POST, "/users/" + userUuid + "/token", UserAPITokenResponse.class, userAPITokenCreateRequest); } @Override - public MeshRequest invalidateAPIToken(String userUuid) { + public MeshRequest invalidateAPIToken(String userUuid, String tokenUuid) { Util.requireUuid(userUuid, "userUuid"); - return prepareRequest(DELETE, "/users/" + userUuid + "/token", GenericMessageResponse.class); + Util.requireUuid(tokenUuid, "tokenUuid"); + return prepareRequest(DELETE, "/users/" + userUuid + "/token/" + tokenUuid, GenericMessageResponse.class); + } + + @Override + public MeshRequest findAPITokens(String userUuid, ParameterProvider... parameters) { + Util.requireUuid(userUuid, "userUuid"); + return prepareRequest(GET, "/users/" + userUuid + "/token" + getQuery(getConfig(), parameters), UserAPITokenListResponse.class); } @Override diff --git a/rest-client/src/main/java/com/gentics/mesh/rest/client/method/UserClientMethods.java b/rest-client/src/main/java/com/gentics/mesh/rest/client/method/UserClientMethods.java index 07f3ee83c7e..3b3b9429bf8 100644 --- a/rest-client/src/main/java/com/gentics/mesh/rest/client/method/UserClientMethods.java +++ b/rest-client/src/main/java/com/gentics/mesh/rest/client/method/UserClientMethods.java @@ -4,6 +4,8 @@ import com.gentics.mesh.core.rest.common.ObjectPermissionGrantRequest; import com.gentics.mesh.core.rest.common.ObjectPermissionResponse; import com.gentics.mesh.core.rest.common.ObjectPermissionRevokeRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenListResponse; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserCreateRequest; import com.gentics.mesh.core.rest.user.UserListResponse; @@ -106,22 +108,31 @@ public interface UserClientMethods { MeshRequest getUserResetToken(String userUuid); /** - * Generate a new API token for the user. The token is valid until a new token is generated. Generating a new token will invalidate the previously generated - * one. + * Generate a new API token for the user. * * @param userUuid * User uuid + * @param request request * @return */ - MeshRequest issueAPIToken(String userUuid); + MeshRequest issueAPIToken(String userUuid, UserAPITokenCreateRequest request); /** - * Invalidate the currently active API token. + * Invalidate the API Token with given uuid. * - * @param userUuid + * @param userUuid User uuid + * @param tokenUuid Token uuid + * @return + */ + MeshRequest invalidateAPIToken(String userUuid, String tokenUuid); + + /** + * Load API Tokens for the given user + * @param userUuid User uuid + * @param parameters optional parameters * @return */ - MeshRequest invalidateAPIToken(String userUuid); + MeshRequest findAPITokens(String userUuid, ParameterProvider... parameters); /** * Get the role permissions on the user diff --git a/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenListResponse.java b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenListResponse.java new file mode 100644 index 00000000000..7f561da3614 --- /dev/null +++ b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenListResponse.java @@ -0,0 +1,10 @@ +package com.gentics.mesh.core.rest.user; + +import com.gentics.mesh.core.rest.common.ListResponse; + +/** + * REST Model for a API Token list response + */ +public class UserAPITokenListResponse extends ListResponse { + +} diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/auth/OAuth2KeycloakPluginTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/auth/OAuth2KeycloakPluginTest.java index 58d7a400510..493449ed759 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/auth/OAuth2KeycloakPluginTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/auth/OAuth2KeycloakPluginTest.java @@ -27,6 +27,7 @@ import com.gentics.mesh.core.rest.node.NodeResponse; import com.gentics.mesh.core.rest.role.RolePermissionRequest; import com.gentics.mesh.core.rest.role.RoleResponse; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserResponse; import com.gentics.mesh.parameter.LinkType; @@ -93,7 +94,7 @@ public void testKeycloakAuth() throws Exception { setAdminToken(); // Now invoke request with regular Mesh API token. - UserAPITokenResponse meshApiToken = call(() -> client().issueAPIToken(me2.getUuid())); + UserAPITokenResponse meshApiToken = call(() -> client().issueAPIToken(me2.getUuid(), new UserAPITokenCreateRequest().setName("Test Token"))); client().logout().blockingGet(); client().setAPIKey(meshApiToken.getToken()); me = call(() -> client().me()); diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java index e21902f8d4a..7e46c4c47d4 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java @@ -10,6 +10,7 @@ import org.junit.Before; import org.junit.Test; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.demo.UserInfo; import com.gentics.mesh.etc.config.MeshOptions; import com.gentics.mesh.test.MeshOptionChanger; @@ -48,7 +49,7 @@ public class MeshRestClientTokenTest extends AbstractMeshTest { @Before public void setUp() throws Exception { String userUuid = TestDataProvider.getInstance().getUserInfo().getUserUuid(); - testUserApiToken = testContext.getHttpClient().issueAPIToken(userUuid).blockingGet().getToken(); + testUserApiToken = testContext.getHttpClient().issueAPIToken(userUuid, new UserAPITokenCreateRequest().setName("Test Token")).blockingGet().getToken(); UserInfo userInfo = TestDataProvider.getInstance().getUserInfo(); username = tx(tx -> { diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/AuthenticationEndpointTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/AuthenticationEndpointTest.java index 510dcfcfaa7..48a1096e1ff 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/AuthenticationEndpointTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/AuthenticationEndpointTest.java @@ -24,6 +24,7 @@ import com.gentics.mesh.core.db.Tx; import com.gentics.mesh.core.rest.common.GenericMessageResponse; import com.gentics.mesh.core.rest.node.NodeResponse; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.core.rest.user.UserResponse; import com.gentics.mesh.rest.client.MeshRestClient; import com.gentics.mesh.rest.client.MeshRestClientMessageException; @@ -76,7 +77,7 @@ public void testRestClient() throws Exception { @Test public void testApiTokenLogin() { UserResponse response = call(() -> client().me()); - String token = call(() -> client().issueAPIToken(response.getUuid())).getToken(); + String token = call(() -> client().issueAPIToken(response.getUuid(), new UserAPITokenCreateRequest().setName("Test Token"))).getToken(); client().setAPIKey(token).setLogin(null, null, null); GenericMessageResponse loginResponse = client().login().blockingGet(); assertNotNull(loginResponse); diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java index 2f190ed2fce..a3a36852cb9 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java @@ -65,6 +65,7 @@ import com.gentics.mesh.core.rest.event.impl.MeshElementEventModelImpl; import com.gentics.mesh.core.rest.node.NodeResponse; import com.gentics.mesh.core.rest.user.NodeReference; +import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserCreateRequest; import com.gentics.mesh.core.rest.user.UserListResponse; @@ -297,12 +298,12 @@ public void testFetchUserToken() { @Test public void testAPIToken() { String uuid = tx(() -> user().getUuid()); - MeshResponse completeResponse = client().issueAPIToken(uuid).getResponse().blockingGet(); + MeshResponse completeResponse = client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName("Test Token")).getResponse().blockingGet(); assertThat(completeResponse.getHeader("Cache-Control")).hasValue("private"); UserAPITokenResponse response = completeResponse.getBody(); -// assertNull("The key was previously not issued.", response.getPreviousIssueDate()); assertThat(response.getToken()).isNotEmpty(); + assertThat(response.getData()).isNotNull(); assertNotNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPIKeyTokenCode(); })); client().setLogin(null, null); @@ -313,21 +314,8 @@ public void testAPIToken() { MeshResponse userResponse = userRequest.getResponse().blockingGet(); assertThat(userResponse.getCookies()).as("Requests using the api key should not yield a new cookie").isEmpty(); - // Now invalidate the api key by generating a new one - String oldKey = response.getToken(); - response = call(() -> client().issueAPIToken(uuid)); - assertNotEquals("Each key should be unique.", oldKey, response.getToken()); -// assertNotNull("The key was already requested once. Thus the date should be set.", response.getPreviousIssueDate()); - - // And continue invoking requests - call(() -> client().findUserByUuid(uuid), UNAUTHORIZED, "error_not_authorized"); - - // Now set the active key and verify that the request works - client().setAPIKey(response.getToken()); - - call(() -> client().findUserByUuid(uuid)); - - call(() -> client().invalidateAPIToken(uuid)); + // new invalidate the token + call(() -> client().invalidateAPIToken(uuid, response.getData().getUuid())); assertNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPIKeyTokenCode(); })); assertNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPITokenIssueTimestamp(); })); call(() -> client().findUserByUuid(uuid), UNAUTHORIZED, "error_not_authorized"); @@ -342,7 +330,7 @@ public void testIssueAPIKeyWithoutPerm() { }); call(() -> client().findUserByUuid(userUuid())); - call(() -> client().issueAPIToken(userUuid()), FORBIDDEN, "error_missing_perm", userUuid(), UPDATE_PERM.getRestPerm().getName()); + call(() -> client().issueAPIToken(userUuid(), new UserAPITokenCreateRequest().setName("Forbidden Token")), FORBIDDEN, "error_missing_perm", userUuid(), UPDATE_PERM.getRestPerm().getName()); } @Test @@ -350,7 +338,7 @@ public void testRevokeAPIKeyWithoutPerm() { String uuid = userUuid(); call(() -> client().findUserByUuid(uuid)); - call(() -> client().issueAPIToken(uuid)); + UserAPITokenResponse tokenResponse = call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName("Test Token"))); tx((tx) -> { RoleDao roleDao = tx.roleDao(); @@ -359,7 +347,7 @@ public void testRevokeAPIKeyWithoutPerm() { tx.success(); }); - call(() -> client().invalidateAPIToken(uuid), FORBIDDEN, "error_missing_perm", uuid, UPDATE_PERM.getRestPerm().getName()); + call(() -> client().invalidateAPIToken(uuid, tokenResponse.getData().getUuid()), FORBIDDEN, "error_missing_perm", uuid, UPDATE_PERM.getRestPerm().getName()); } @Test From ddcf4b99cb50a8754e6464c02dfef072c3a6198f Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Fri, 24 Jul 2026 11:41:36 +0200 Subject: [PATCH 07/14] Add API Token Management Tests --- .../mesh/core/user/UserEndpointTest.java | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java index a3a36852cb9..ffe2584c6f5 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java @@ -38,8 +38,12 @@ import static org.junit.Assert.assertTrue; import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; @@ -66,6 +70,8 @@ import com.gentics.mesh.core.rest.node.NodeResponse; import com.gentics.mesh.core.rest.user.NodeReference; import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; +import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.core.rest.user.UserAPITokenListResponse; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserCreateRequest; import com.gentics.mesh.core.rest.user.UserListResponse; @@ -84,6 +90,7 @@ import com.gentics.mesh.test.MeshTestSetting; import com.gentics.mesh.test.context.AbstractMeshTest; import com.gentics.mesh.test.definition.BasicRestTestcases; +import com.gentics.mesh.util.DateUtils; import com.gentics.mesh.util.UUIDUtil; import io.vertx.core.json.JsonObject; @@ -305,7 +312,9 @@ public void testAPIToken() { assertThat(response.getToken()).isNotEmpty(); assertThat(response.getData()).isNotNull(); - assertNotNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPIKeyTokenCode(); })); + UserAPITokenListResponse tokens = call(() -> client().findAPITokens(uuid)); + assertThat(tokens.getData()).containsOnly(response.getData()); + client().setLogin(null, null); client().setAPIKey(response.getToken()); @@ -321,6 +330,70 @@ public void testAPIToken() { call(() -> client().findUserByUuid(uuid), UNAUTHORIZED, "error_not_authorized"); } + @Test + public void testAPITokenDuplicateName() { + String uuid = tx(() -> user().getUuid()); + String conflictingName = "Conflicting Name"; + call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName(conflictingName))); + + call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName(conflictingName)), CONFLICT, + "apitoken_conflicting_name"); + } + + @Test + public void testAPITokenWithExpiration() { + String uuid = tx(() -> user().getUuid()); + String name = "Test Token with Expiration"; + String expires = DateUtils.toISO8601(Instant.now().plus(1, ChronoUnit.MINUTES).toEpochMilli()); + UserAPITokenResponse tokenResponse = call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName(name).setExpires(expires))); + + assertThat(tokenResponse.getData()).as("Created token") + .hasFieldOrPropertyWithValue("name", name) + .hasFieldOrPropertyWithValue("expires", expires) + .hasFieldOrPropertyWithValue("valid", true); + } + + @Test + public void testAPITokenWithExpirationInPast() { + String uuid = tx(() -> user().getUuid()); + String name = "Test Token with Expiration"; + String expires = DateUtils.toISO8601(Instant.now().minus(1, ChronoUnit.MINUTES).toEpochMilli()); + call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName(name).setExpires(expires)), BAD_REQUEST, "apitoken_expires_in_past"); + } + + @Test + public void testListAPITokens() { + int numTokens = 50; + Set tokens = new HashSet<>(); + String userUuid = tx(() -> user().getUuid()); + String foreignUserUuid = tx(() -> users().get("admin").getUuid()); + + for (int i = 0; i < numTokens; i++) { + String name = "Token #%d".formatted(i); + + tokens.add(call(() -> client().issueAPIToken(userUuid, new UserAPITokenCreateRequest().setName(name))).getData()); + call(() -> client().issueAPIToken(foreignUserUuid, new UserAPITokenCreateRequest().setName(name))); + } + + UserAPITokenListResponse tokenList = call(() -> client().findAPITokens(userUuid)); + assertThat(tokenList.getData()).as("Tokens of user").hasSameElementsAs(tokens); + } + + @Test + public void testListAPITokensWithoutPerm() { + String uuid = tx(() -> user().getUuid()); + + call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName("Test Token"))); + + tx((tx) -> { + RoleDao roleDao = tx.roleDao(); + roleDao.revokePermissions(role(), user(), UPDATE_PERM); + tx.success(); + }); + + call(() -> client().findAPITokens(uuid), FORBIDDEN, "error_missing_perm", uuid, UPDATE_PERM.getRestPerm().getName()); + } + @Test public void testIssueAPIKeyWithoutPerm() { tx((tx) -> { From 9e6b92cd37db6edf40e94a4938cc9f55d36d7d74 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Fri, 24 Jul 2026 12:51:30 +0200 Subject: [PATCH 08/14] Fix compile error --- .../java/com/gentics/mesh/core/user/APITokenTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java index 53387946b5b..668b008fd92 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java @@ -14,7 +14,6 @@ import com.gentics.mesh.context.InternalActionContext; import com.gentics.mesh.core.data.dao.APITokenDao; -import com.gentics.mesh.core.data.page.Page; import com.gentics.mesh.core.data.user.HibAPITokenData; import com.gentics.mesh.core.data.user.HibUser; import com.gentics.mesh.core.rest.error.NameConflictException; @@ -243,12 +242,13 @@ public void testList() { }); } - Page list = tx(tx -> { + Set existing = new HashSet<>(); + existing.addAll(tx(tx -> { InternalActionContext ac = mockActionContext(); return tx.apiTokenDao().findAll(ac, user(), new PagingParametersImpl(1, 1000L)); - }); + }).getWrappedList()); - assertThat(list).as("List of API Tokens").hasSameElementsAs(tokens); + assertThat(existing).as("List of API Tokens").hasSameElementsAs(tokens); } /** From 5f5c7cfaafa2f1003cdba178d16f2b781eb11765 Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Fri, 24 Jul 2026 13:40:33 +0200 Subject: [PATCH 09/14] Add test with expired API Token --- .../mesh/client/MeshRestClientTokenTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java index 7e46c4c47d4..db98164f10f 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/client/MeshRestClientTokenTest.java @@ -5,6 +5,8 @@ import static com.gentics.mesh.test.ElasticsearchTestMode.NONE; import static com.gentics.mesh.test.TestSize.PROJECT; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; import org.junit.Before; @@ -17,6 +19,7 @@ import com.gentics.mesh.test.MeshTestSetting; import com.gentics.mesh.test.TestDataProvider; import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.util.DateUtils; import io.netty.handler.codec.http.HttpResponseStatus; import io.reactivex.Flowable; @@ -100,6 +103,27 @@ public void testApiToken() throws Exception { }).blockingSubscribe(); } + /** + * Test using an expired API Token + * @throws Exception + */ + @Test + public void testExpiredApiToken() throws Exception { + String uuid = tx(() -> user().getUuid()); + + // create a token, which will expire in one second + String expires = DateUtils.toISO8601(Instant.now().plus(1, ChronoUnit.SECONDS).toEpochMilli()); + client().setLogin(username, password).login().blockingGet(); + String expiredToken = call(() -> client().issueAPIToken(uuid, + new UserAPITokenCreateRequest().setName("Expired token").setExpires(expires))).getToken(); + + // wait two seconds + Thread.sleep(2_000); + + client().setLogin(null, null).setAPIKey(expiredToken); + call(() -> client().me(), HttpResponseStatus.UNAUTHORIZED); + } + /** * Test that the token expires if waiting too long * @throws Exception From 7149df304720b4703eb0597b59680c10d1d4783b Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Mon, 27 Jul 2026 13:14:21 +0200 Subject: [PATCH 10/14] Implement migration of API Tokens --- .../v3_3_0/MigrateUserAPITokens.java | 131 ++++++++++++++++ .../1-features/changelog-gpu-2604.xml | 8 + .../mesh/core/data/NodeMigrationUser.java | 25 --- .../gentics/mesh/core/data/user/HibUser.java | 48 ------ .../hibernate/data/domain/HibUserImpl.java | 34 +---- .../mesh/test/context/MeshTestContext.java | 2 + .../mesh/test/local/MeshLocalServer.java | 5 + .../mesh/test/MeshTestInitializer.java | 20 +++ .../gentics/mesh/test/MeshTestSetting.java | 7 + .../mesh/core/user/UserEndpointTest.java | 2 - .../mesh/search/AbstractMultiESTest.java | 6 + tests/tests-hibernate/pom.xml | 4 + .../mesh/core/user/APITokenMigrationTest.java | 144 ++++++++++++++++++ 13 files changed, 328 insertions(+), 108 deletions(-) create mode 100644 connectors/common/src/main/java/com/gentics/mesh/liquibase/changelog/v3_3_0/MigrateUserAPITokens.java create mode 100644 tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestInitializer.java create mode 100644 tests/tests-hibernate/src/main/java/com/gentics/mesh/core/user/APITokenMigrationTest.java diff --git a/connectors/common/src/main/java/com/gentics/mesh/liquibase/changelog/v3_3_0/MigrateUserAPITokens.java b/connectors/common/src/main/java/com/gentics/mesh/liquibase/changelog/v3_3_0/MigrateUserAPITokens.java new file mode 100644 index 00000000000..cef955f3791 --- /dev/null +++ b/connectors/common/src/main/java/com/gentics/mesh/liquibase/changelog/v3_3_0/MigrateUserAPITokens.java @@ -0,0 +1,131 @@ +package com.gentics.mesh.liquibase.changelog.v3_3_0; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Triple; + +import com.gentics.mesh.util.UUIDUtil; + +import liquibase.change.custom.CustomTaskChange; +import liquibase.database.Database; +import liquibase.database.jvm.JdbcConnection; +import liquibase.exception.CustomChangeException; +import liquibase.exception.CustomPreconditionErrorException; +import liquibase.exception.CustomPreconditionFailedException; +import liquibase.exception.DatabaseException; +import liquibase.exception.SetupException; +import liquibase.exception.ValidationErrors; +import liquibase.precondition.CustomPrecondition; +import liquibase.resource.ResourceAccessor; + +/** + * Custom change task that migrates API Tokens stored in the user table to their + * own entities + */ +public class MigrateUserAPITokens implements CustomTaskChange, CustomPrecondition { + /** + * Name of the system property, which will omit this change, when set to "true" + */ + public final static String OMIT_PRECONDITION = "MigrateUserAPITokens.omit"; + + /** + * Create a random UUID and return it in the same format as the given uuid + * @param uuid example UUID + * @return random UUId in the same format as the example + */ + protected static Object createRandomUuid(Object uuid) throws CustomChangeException { + String randomUUID = UUIDUtil.randomUUID(); + if (uuid instanceof byte[]) { + return UUIDUtil.toBytes(UUIDUtil.toJavaUuid(randomUUID)); + } else if (uuid instanceof UUID) { + return UUIDUtil.toJavaUuid(randomUUID); + } else if (uuid instanceof String) { + return UUIDUtil.toFullUuid(randomUUID); + } else { + throw new CustomChangeException("UUID has unknown type %s".formatted(uuid.getClass())); + } + } + + @Override + public String getConfirmationMessage() { + return "MigrateUserAPITokens confirmed"; + } + + @Override + public void setUp() throws SetupException { + } + + @Override + public void setFileOpener(ResourceAccessor resourceAccessor) { + } + + @Override + public ValidationErrors validate(Database database) { + return new ValidationErrors(); + } + + @Override + public void execute(Database database) throws CustomChangeException { + JdbcConnection conn = (JdbcConnection) database.getConnection(); + + List> tokens = new ArrayList<>(); + try (PreparedStatement pst = conn.prepareStatement( + "SELECT dbuuid, apitokenid, apitokenissuetimestamp FROM mesh_user")) { + try (ResultSet rs = pst.executeQuery()) { + while (rs.next()) { + Object uuid = rs.getObject("dbuuid"); + String apiTokenId = rs.getString("apitokenid"); + Long apiTokenIssueTimestamp = rs.getLong("apitokenissuetimestamp"); + + if (StringUtils.isNotBlank(apiTokenId)) { + tokens.add(Triple.of(uuid, apiTokenId, apiTokenIssueTimestamp)); + } + } + } + } catch (DatabaseException | SQLException e) { + throw new CustomChangeException(e); + } + + if (!tokens.isEmpty()) { + try (PreparedStatement pst = conn.prepareStatement( + "INSERT INTO mesh_apitoken (dbuuid, dbversion, name, user_dbuuid, tokenid, issued, lastused, expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { + for (Triple token : tokens) { + Object uuid = createRandomUuid(token.getLeft()); + Timestamp issued = Timestamp.from(Instant.ofEpochMilli(token.getRight())); + Timestamp zero = Timestamp.from(Instant.ofEpochMilli(0)); + + pst.setObject(1, uuid); // dbuuid + pst.setLong(2, 1); // dbversion + pst.setString(3, "Migrated API Token"); // name + pst.setObject(4, token.getLeft()); // user_dbuuid + pst.setString(5, token.getMiddle()); // tokenid + pst.setTimestamp(6, issued); // issued + pst.setTimestamp(7, zero); // lastused + pst.setTimestamp(8, zero); // expires + + pst.addBatch(); + } + + pst.executeBatch(); + } catch (DatabaseException | SQLException e) { + throw new CustomChangeException(e); + } + } + + } + + @Override + public void check(Database database) throws CustomPreconditionFailedException, CustomPreconditionErrorException { + if ("true".equals(System.getProperty(OMIT_PRECONDITION))) { + throw new CustomPreconditionFailedException("Skipped in test"); + } + } +} diff --git a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml index f3c9986f26c..25bdfdf35d8 100644 --- a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml +++ b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml @@ -40,4 +40,12 @@ + + + + + + + + diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/NodeMigrationUser.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/NodeMigrationUser.java index bdf9b32d1b2..48ed39ae95b 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/NodeMigrationUser.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/NodeMigrationUser.java @@ -110,21 +110,6 @@ public HibUser setResetTokenIssueTimestamp(Long timestamp) { return null; } - @Override - public String getAPIKeyTokenCode() { - return null; - } - - @Override - public HibUser setAPITokenId(String code) { - return null; - } - - @Override - public HibUser setAPITokenIssueTimestamp() { - return null; - } - @Override public MeshElementEventModel onCreated() { return null; @@ -181,16 +166,6 @@ public HibUser setPasswordHash(String hash) { return this; } - @Override - public Long getAPITokenIssueTimestamp() { - return null; - } - - @Override - public void resetAPIToken() { - - } - @Override public HibUser setResetToken(String token) { return this; diff --git a/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibUser.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibUser.java index 86179751184..2c54557a820 100644 --- a/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibUser.java +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibUser.java @@ -19,7 +19,6 @@ import com.gentics.mesh.core.rest.user.UserReference; import com.gentics.mesh.core.rest.user.UserResponse; import com.gentics.mesh.handler.VersionUtils; -import com.gentics.mesh.util.DateUtils; import io.vertx.ext.auth.User; @@ -203,20 +202,6 @@ default HibUser invalidateResetToken() { */ void setAdmin(boolean flag); - /** - * Return the currently stored API token id. - * - * @return API token id or null if no token has yet been generated. - */ - String getAPIKeyTokenCode(); - - /** - * Return the timestamp when the api key token code was last issued. - * - * @return - */ - Long getAPITokenIssueTimestamp(); - /** * Return an iterable of roles which belong to this user. Internally this will fetch all groups of the user and collect the assigned roles. * @@ -225,11 +210,6 @@ default HibUser invalidateResetToken() { // TODO we want to use this to the user dao // Iterable getRoles(); - /** - * Reset the API token id and issue timestamp and thus invalidating the token. - */ - void resetAPIToken(); - /** * Set the reset token for the user. * @@ -238,14 +218,6 @@ default HibUser invalidateResetToken() { */ HibUser setResetToken(String token); - /** - * Set the user API token id. - * - * @param code - * @return Fluent API - */ - HibUser setAPITokenId(String code); - /** * Set the token code issue timestamp. This is used to influence the token expire moment. * @@ -254,26 +226,6 @@ default HibUser invalidateResetToken() { */ HibUser setResetTokenIssueTimestamp(Long timestamp); - /** - * Set the API token issue timestamp to the current time. - * - * @return Fluent API - */ - HibUser setAPITokenIssueTimestamp(); - - /** - * Return the API token issue date. - * - * @return ISO8601 formatted date or null if the date has not yet been set - */ - default String getAPITokenIssueDate() { - Long timestamp = getAPITokenIssueTimestamp(); - if (timestamp == null) { - return null; - } - return DateUtils.toISO8601(timestamp, System.currentTimeMillis()); - } - /** * Return the currently stored reset token. * diff --git a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibUserImpl.java b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibUserImpl.java index a80688e1aed..38af2c5cc30 100644 --- a/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibUserImpl.java +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibUserImpl.java @@ -70,12 +70,8 @@ public class HibUserImpl extends AbstractHibUserTrackedElement imp private String passwordHash; private String resetToken; - + private Long resetTokenIssueTimestamp; - - private String apiTokenId; - - private Long apiTokenIssueTimestamp; private boolean forcedPasswordChange; @@ -200,46 +196,18 @@ public void setAdmin(boolean admin) { this.admin = admin; } - @Override - public String getAPIKeyTokenCode() { - return apiTokenId; - } - - @Override - public Long getAPITokenIssueTimestamp() { - return apiTokenIssueTimestamp; - } - - @Override - public void resetAPIToken() { - apiTokenId = null; - apiTokenIssueTimestamp = null; - } - @Override public HibUser setResetToken(String token) { resetToken = token; return this; } - @Override - public HibUser setAPITokenId(String code) { - apiTokenId = code; - return this; - } - @Override public HibUser setResetTokenIssueTimestamp(Long timestamp) { resetTokenIssueTimestamp = timestamp; return this; } - @Override - public HibUser setAPITokenIssueTimestamp() { - apiTokenIssueTimestamp = System.currentTimeMillis(); - return this; - } - @Override public String getResetToken() { return resetToken; diff --git a/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java b/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java index 074b2a0e59a..ea0d8e37c31 100644 --- a/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java +++ b/tests/common/src/main/java/com/gentics/mesh/test/context/MeshTestContext.java @@ -226,6 +226,8 @@ public void setupOnce(MeshTestSetting settings) throws Exception { throw new RuntimeException("Settings could not be found. Did you forget to add the @MeshTestSetting annotation to your test?"); } + settings.initializer().getConstructor().newInstance().init(); + int numberOfInstances = settings.clusterMode() ? settings.clusterInstances() : 1; meshTestContextProvider = MeshTestContextProvider.getProvider(); diff --git a/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java b/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java index 3ba2c8e974e..d9954c74f41 100644 --- a/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java +++ b/tests/common/src/main/java/com/gentics/mesh/test/local/MeshLocalServer.java @@ -22,6 +22,7 @@ import com.gentics.mesh.test.MeshOptionChanger; import com.gentics.mesh.test.MeshOptionChanger.NoOptionChanger; import com.gentics.mesh.test.MeshTestContextProvider; +import com.gentics.mesh.test.MeshTestInitializer; import com.gentics.mesh.test.MeshTestServer; import com.gentics.mesh.test.MeshTestSetting; import com.gentics.mesh.test.ResetTestDb; @@ -370,5 +371,9 @@ public String[] nodeNames() { public AWSTestMode awsContainer() { return AWSTestMode.NONE; } + + public java.lang.Class initializer() { + return MeshTestInitializer.NoOptionInitializer.class; + }; }; } diff --git a/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestInitializer.java b/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestInitializer.java new file mode 100644 index 00000000000..15c7fbd7c5f --- /dev/null +++ b/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestInitializer.java @@ -0,0 +1,20 @@ +package com.gentics.mesh.test; + +/** + * Interface for test initializers. The method {@link #init()} will be called before anything else happens + */ +public interface MeshTestInitializer { + /** + * Do some test initialization + */ + void init(); + + /** + * No-op implementation of {@link MeshTestInitializer} + */ + static final class NoOptionInitializer implements MeshTestInitializer { + @Override + public void init() { + } + } +} diff --git a/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java b/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java index 6e3f6bc484d..ec39f49047c 100644 --- a/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java +++ b/tests/context-api/src/main/java/com/gentics/mesh/test/MeshTestSetting.java @@ -4,6 +4,7 @@ import java.lang.annotation.RetentionPolicy; import com.gentics.mesh.test.MeshOptionChanger.NoOptionChanger; +import com.gentics.mesh.test.MeshTestInitializer.NoOptionInitializer; /** * @@ -123,4 +124,10 @@ * @return */ ResetTestDb resetBetweenTests() default ResetTestDb.ALWAYS; + + /** + * Class of an initializer to use. The initialize will be called before anything else + * @return class + */ + Class initializer() default NoOptionInitializer.class; } diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java index ffe2584c6f5..f8ed357671d 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/UserEndpointTest.java @@ -325,8 +325,6 @@ public void testAPIToken() { // new invalidate the token call(() -> client().invalidateAPIToken(uuid, response.getData().getUuid())); - assertNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPIKeyTokenCode(); })); - assertNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPITokenIssueTimestamp(); })); call(() -> client().findUserByUuid(uuid), UNAUTHORIZED, "error_not_authorized"); } diff --git a/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java b/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java index 83786b94497..5b1f062008d 100644 --- a/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java +++ b/tests/tests-core/src/main/java/com/gentics/mesh/search/AbstractMultiESTest.java @@ -23,6 +23,7 @@ import com.gentics.mesh.test.ElasticsearchTestMode; import com.gentics.mesh.test.MeshCoreOptionChanger; import com.gentics.mesh.test.MeshOptionChanger; +import com.gentics.mesh.test.MeshTestInitializer; import com.gentics.mesh.test.MeshTestSetting; import com.gentics.mesh.test.ResetTestDb; import com.gentics.mesh.test.SSLTestMode; @@ -208,6 +209,11 @@ public Class customOptionChanger() { public ResetTestDb resetBetweenTests() { return delegate.resetBetweenTests(); } + + @Override + public Class initializer() { + return delegate.initializer(); + } } @Override diff --git a/tests/tests-hibernate/pom.xml b/tests/tests-hibernate/pom.xml index 58073a177fa..fffa3756ca7 100644 --- a/tests/tests-hibernate/pom.xml +++ b/tests/tests-hibernate/pom.xml @@ -25,6 +25,10 @@ com.gentics.mesh mesh-mdm-hibernate-core + + com.gentics.mesh + mesh-database-connector-common + com.gentics.mesh tests-mesh-core diff --git a/tests/tests-hibernate/src/main/java/com/gentics/mesh/core/user/APITokenMigrationTest.java b/tests/tests-hibernate/src/main/java/com/gentics/mesh/core/user/APITokenMigrationTest.java new file mode 100644 index 00000000000..2859e9207c4 --- /dev/null +++ b/tests/tests-hibernate/src/main/java/com/gentics/mesh/core/user/APITokenMigrationTest.java @@ -0,0 +1,144 @@ +package com.gentics.mesh.core.user; + +import static com.gentics.mesh.test.TestSize.FULL; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.RandomUtils; +import org.apache.commons.lang3.Strings; +import org.apache.commons.lang3.tuple.Pair; +import org.assertj.core.api.Condition; +import org.hibernate.Session; +import org.junit.Test; + +import com.gentics.mesh.context.InternalActionContext; +import com.gentics.mesh.core.data.dao.APITokenDao; +import com.gentics.mesh.core.data.user.HibAPITokenData; +import com.gentics.mesh.core.data.user.HibUser; +import com.gentics.mesh.database.HibernateTx; +import com.gentics.mesh.hibernate.data.domain.HibUserImpl; +import com.gentics.mesh.liquibase.LiquibaseConnectionProvider; +import com.gentics.mesh.liquibase.LiquibaseLogService; +import com.gentics.mesh.liquibase.LiquibaseStartupContext; +import com.gentics.mesh.liquibase.LiquibaseUIService; +import com.gentics.mesh.liquibase.changelog.v3_3_0.MigrateUserAPITokens; +import com.gentics.mesh.parameter.PagingParameters; +import com.gentics.mesh.parameter.impl.PagingParametersImpl; +import com.gentics.mesh.test.MeshTestInitializer; +import com.gentics.mesh.test.MeshTestSetting; +import com.gentics.mesh.test.context.AbstractMeshTest; +import com.gentics.mesh.util.TokenUtil; + +import jakarta.persistence.EntityManager; +import liquibase.Contexts; +import liquibase.Liquibase; +import liquibase.Scope; + +/** + * Test for automatic migration of user API Tokens via a custom liqubase change + */ +@MeshTestSetting(testSize = FULL, startServer = false, initializer = APITokenMigrationTest.class) +public class APITokenMigrationTest extends AbstractMeshTest implements MeshTestInitializer { + public final static List usersWithApiTokens = List.of("joe1", "guest"); + + @Override + public void init() { + // first set the system property to omit the migration + System.setProperty(MigrateUserAPITokens.OMIT_PRECONDITION, "true"); + } + + @Test + public void testMigration() throws Exception { + Map> apiTokens = new HashMap<>(); + + // prepare old API Tokens + tx(tx -> { + HibernateTx hTx = tx.unwrap(); + + EntityManager em = hTx.entityManager(); + String userTableName = hTx.data().getDatabaseConnector().maybeGetPhysicalTableName(HibUserImpl.class).get(); + + for (String userName : usersWithApiTokens) { + String userUuid = users().get(userName).getUuid(); + String tokenId = TokenUtil.randomToken(); + long issuedAt = RandomUtils.insecure().randomLong( + Instant.now().minus(10, ChronoUnit.DAYS).toEpochMilli(), + Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli()); + apiTokens.put(userUuid, Pair.of(tokenId, issuedAt)); + em.createNativeQuery("UPDATE %s SET apitokenid = ?, apitokenissuetimestamp = ? WHERE name = ?".formatted(userTableName)) + .setParameter(1, tokenId) + .setParameter(2, issuedAt) + .setParameter(3, userName) + .executeUpdate(); + } + }); + + // reset the system property to no longer omit the migration + System.setProperty(MigrateUserAPITokens.OMIT_PRECONDITION, "false"); + + // let liquibase run again to do the migration + tx(tx -> { + HibernateTx hTx = tx.unwrap(); + EntityManager em = hTx.entityManager(); + LiquibaseConnectionProvider liquibaseConnectionProvider = new LiquibaseConnectionProvider( + hTx.data().options(), hTx.data().getDatabaseConnector()); + + Session session = (Session) em.getDelegate(); + session.doWork(connection -> { + try { + Scope.child(Map.of(Scope.Attr.ui.name(), new LiquibaseUIService(), Scope.Attr.logService.name(), + new LiquibaseLogService()), () -> { + try (LiquibaseStartupContext lbctx = liquibaseConnectionProvider.getLiquibase(connection)) { + Liquibase liquibase = lbctx.liquibase(); + liquibase.update((Contexts) null); + } + }); + } catch (Exception e) { + log.error("Database migration failed", e); + throw new RuntimeException("Database migration failed", e); + } finally { + em.close(); + } + }); + }); + + // check whether tokens have been migrated + for (HibUser user : users().values()) { + List tokens = tx(tx -> { + InternalActionContext ac = mockActionContext(); + PagingParameters paging = new PagingParametersImpl(); + + APITokenDao apiTokenDao = tx.apiTokenDao(); + return apiTokenDao.findAll(ac, user, paging).getWrappedList(); + }); + + if (apiTokens.containsKey(user.getUuid())) { + String tokenId = apiTokens.get(user.getUuid()).getLeft(); + Long issuedAt = apiTokens.get(user.getUuid()).getRight(); + Condition tokenIdCondition = new Condition( + token -> Strings.CS.equals(token.getTokenId(), tokenId), "Correct tokenId"); + Condition issuedAtCondition = new Condition( + token -> Objects.equals(token.getIssuedTimestamp(), issuedAt), "Correct issue date"); + Condition nameCondition = new Condition( + token -> Strings.CS.equals(token.getName(), "Migrated API Token"), "Correct name"); + Condition expiresCondition = new Condition( + token -> Objects.equals(token.getExpiresTimestamp(), 0L), "Correct expiration date"); + + assertThat(tokens).as("Tokens for %s".formatted(user.getName())) + .hasSize(1) + .areExactly(1, tokenIdCondition) + .areExactly(1, issuedAtCondition) + .areExactly(1, nameCondition) + .areExactly(1, expiresCondition); + } else { + assertThat(tokens).as("Tokens for %s".formatted(user.getName())).isEmpty(); + } + } + } +} From c77770c07bd710707179aa15e804be7cce87e7be Mon Sep 17 00:00:00 2001 From: Norbert Pomaroli Date: Mon, 27 Jul 2026 13:53:08 +0200 Subject: [PATCH 11/14] Add changelog entry and adapt documentation --- .../entries/2026/07/8840.GPU-2604.optional-manualchange | 4 ++++ doc/src/main/hugo/content/docs/authentication.asciidoc | 9 ++++----- doc/src/main/hugo/content/docs/building-blocks.asciidoc | 7 +++++-- .../content/docs/guides/mesh-api-intro/index.asciidoc | 2 +- doc/src/main/hugo/content/docs/security.asciidoc | 5 ++--- 5 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 changelog/src/changelog/entries/2026/07/8840.GPU-2604.optional-manualchange diff --git a/changelog/src/changelog/entries/2026/07/8840.GPU-2604.optional-manualchange b/changelog/src/changelog/entries/2026/07/8840.GPU-2604.optional-manualchange new file mode 100644 index 00000000000..ec202aaf8d9 --- /dev/null +++ b/changelog/src/changelog/entries/2026/07/8840.GPU-2604.optional-manualchange @@ -0,0 +1,4 @@ +Auth: The management of API Tokens for users has been enhanced to support multiple tokens per user. +New tokens must now be created with a `name` and optionally can have an `expires` date. +See the "Mesh Restful API documentation":https://www.gentics.com/mesh/docs/api/#users__userUuid__token_post +for details. diff --git a/doc/src/main/hugo/content/docs/authentication.asciidoc b/doc/src/main/hugo/content/docs/authentication.asciidoc index 5ba034d32f2..77d0835ab3f 100644 --- a/doc/src/main/hugo/content/docs/authentication.asciidoc +++ b/doc/src/main/hugo/content/docs/authentication.asciidoc @@ -144,15 +144,14 @@ curl -X GET \ === API Token -An API token will never expire. This is different from regular tokens which will be issued when calling `{apiLatest}/auth/login`. +API tokens can be created with optional expiration. This is different from regular tokens which will be issued when calling `{apiLatest}/auth/login`. WARNING: Leaking an API token is potentially dangerous and thus the API token should only be used in combination with a secure connection. Typical use cases for API tokens are backend implementations which constantly communicate with Gentics Mesh using a secure or local connection. -The token can be issued per user with ```POST {apiLatest}/users/:userUuid/token```. Note that update permissions is required on the user associated with the provided userUuid. - -NOTE: Creating a new API token will automatically invalidate a previously issued token. +Tokens can be issued per user with ```POST {apiLatest}/users/:userUuid/token```. It must be given a unique name and can optionally have an expiration date. +Note that update permissions is required on the user associated with the provided userUuid. Since the token is just a regular JWT you just need to add it to your request `Authorization` header field. @@ -163,7 +162,7 @@ curl -X GET \ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyVXVpZCI6IjNjYjY2YzU0MmFlMzRiMDFiNjZjNTQyYWUzY2IwMWRiIiwiaWF0IjoxNDkxNzY1NDEzLCJleHAiOjE0OTE3NjkwMTN9.UY8OgjiK5qyZobAWt6X1Vd1Z-zg68BeJgGZKbW4Ucj0=' \ ---- -It is possible to manually revoke a previously issued token via ```DELETE {apiLatest}/users/:userUuid/token```. Once the token is invalidated it can no longer be used for authentication. This requires delete permission on the user associated with the provided userUuid. +It is possible to manually revoke a previously issued token via ```DELETE {apiLatest}/users/:userUuid/token/:tokenUuid```. Once the token is invalidated it can no longer be used for authentication. This requires update permission on the user associated with the provided userUuid. === Anonymous Access diff --git a/doc/src/main/hugo/content/docs/building-blocks.asciidoc b/doc/src/main/hugo/content/docs/building-blocks.asciidoc index 09aa1918c54..d7ca1d0c3b3 100644 --- a/doc/src/main/hugo/content/docs/building-blocks.asciidoc +++ b/doc/src/main/hugo/content/docs/building-blocks.asciidoc @@ -548,11 +548,14 @@ In order to be able to store and retrieve content, a user needs to authenticate | Return a one time token to update a user | link:++/docs/api#users__userUuid__reset_token_post++[```POST {apiLatest}/users/:userUuid/reset_token```] -| Return an API token +| Create an API token | link:++/docs/api#users__userUuid__token_post++[```POST {apiLatest}/users/:userUuid/token```] +| List the API tokens +| link:++/docs/api#users__userUuid__token_get++[```GET {apiLatest}/users/:userUuid/token```] + | Invalidate an API token -| link:++/docs/api#users__userUuid__token_delete++[```DELETE {apiLatest}/users/:userUuid/token```] +| link:++/docs/api#users__userUuid__token_delete++[```DELETE {apiLatest}/users/:userUuid/token/:tokenUuid```] |====== diff --git a/doc/src/main/hugo/content/docs/guides/mesh-api-intro/index.asciidoc b/doc/src/main/hugo/content/docs/guides/mesh-api-intro/index.asciidoc index fe5d41494d3..d6acb16709b 100644 --- a/doc/src/main/hugo/content/docs/guides/mesh-api-intro/index.asciidoc +++ b/doc/src/main/hugo/content/docs/guides/mesh-api-intro/index.asciidoc @@ -81,7 +81,7 @@ curl -X POST \ Gentics Mesh uses JWT to handle authentication. The token we just got is valid for one hour. -TIP: It is also possible to get link:{{< relref "authentication.asciidoc" >}}#_api_token[API tokens] which will never expire. Long running tasks such as importers should use such tokens. +TIP: It is also possible to get link:{{< relref "authentication.asciidoc" >}}#_api_token[API tokens] which may be created to never expire. Long running tasks such as importers should use such tokens. TIP: Authentication is not mandatory. It is also possible to use most of the Gentics Mesh API using the link:{{< relref "authentication.asciidoc" >}}#_anonymous_access[anonymous access] feature. This is especially useful if you want to use a SPA directly with Gentics Mesh. diff --git a/doc/src/main/hugo/content/docs/security.asciidoc b/doc/src/main/hugo/content/docs/security.asciidoc index dfaf4c69c1c..974d1a1c89d 100644 --- a/doc/src/main/hugo/content/docs/security.asciidoc +++ b/doc/src/main/hugo/content/docs/security.asciidoc @@ -8,10 +8,9 @@ This section will cover information about Gentics Mesh security related topics. == API Tokens -API tokens can be generated via the `{apiLatest}/users/:uuid/token`. +API tokens can be generated via the `POST {apiLatest}/users/:userUuid/token` endpoint. These tokens should not be publicly exposed since they they are equivalent to user credentials. -Tokens can be reset via the `{apiLatest}/users/:uuid/reset_token` endpoint. -This will invalidate any previously generated API token. +Tokens can be removed via the `DELETE {apiLatest}/users/:userUuid/token/:tokenUuid` endpoint. == Server Tokens From 685fb8495b7315668f2b030433d31d3bb984dff5 Mon Sep 17 00:00:00 2001 From: Serhii Plyhun Date: Tue, 11 Aug 2026 16:06:12 +0200 Subject: [PATCH 12/14] GPU-2659: Fix wrong column name in the changelog --- .../liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml index 25bdfdf35d8..256556e2654 100644 --- a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml +++ b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml @@ -45,7 +45,7 @@ - + From c234a57df99bbde75e62488525d16aecd10d4287 Mon Sep 17 00:00:00 2001 From: Serhii Plyhun Date: Tue, 11 Aug 2026 16:09:17 +0200 Subject: [PATCH 13/14] Fix the changelog order --- .../liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml index 256556e2654..abf094b4b6c 100644 --- a/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml +++ b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml @@ -45,6 +45,8 @@ + + From 3cf5367a05d8a78c2f8c43ba888a015644b8615a Mon Sep 17 00:00:00 2001 From: Serhii Plyhun Date: Thu, 27 Aug 2026 10:40:37 +0200 Subject: [PATCH 14/14] Fill the missing spec description --- .../com/gentics/mesh/core/endpoint/user/UserEndpoint.java | 2 +- .../main/java/com/gentics/mesh/example/UserExamples.java | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java index 5df86f19d1d..af36650d9b2 100644 --- a/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java +++ b/core/src/main/java/com/gentics/mesh/core/endpoint/user/UserEndpoint.java @@ -116,7 +116,7 @@ private void addAPITokenHandler() { listEndpoint.method(GET); listEndpoint.setMutating(false); listEndpoint.produces(APPLICATION_JSON); - listEndpoint.exampleResponse(OK, null); // FIXME + listEndpoint.exampleResponse(OK, userExamples.getAPITokenListResponse("Some API Token"), "API token list response"); listEndpoint.blockingHandler(rc -> { InternalActionContext ac = wrap(rc); String userUuid = ac.getParameter("userUuid"); diff --git a/mdm/common/src/main/java/com/gentics/mesh/example/UserExamples.java b/mdm/common/src/main/java/com/gentics/mesh/example/UserExamples.java index 5b3987cba9d..af4fbdf43b0 100644 --- a/mdm/common/src/main/java/com/gentics/mesh/example/UserExamples.java +++ b/mdm/common/src/main/java/com/gentics/mesh/example/UserExamples.java @@ -18,6 +18,7 @@ import com.gentics.mesh.core.rest.user.NodeReference; import com.gentics.mesh.core.rest.user.UserAPITokenCreateRequest; import com.gentics.mesh.core.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.core.rest.user.UserAPITokenListResponse; import com.gentics.mesh.core.rest.user.UserAPITokenResponse; import com.gentics.mesh.core.rest.user.UserCreateRequest; import com.gentics.mesh.core.rest.user.UserListResponse; @@ -146,4 +147,10 @@ public UserAPITokenCreateRequest getAPITokenCreateRequest(String name) { .setName(name) .setExpires(DateUtils.toISO8601(Instant.parse("2026-12-24T18:00:00.00Z").toEpochMilli())); } + + public UserAPITokenListResponse getAPITokenListResponse(String name) { + UserAPITokenListResponse list = new UserAPITokenListResponse(); + list.add(getAPITokenData(name)); + return list; + } }