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/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/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..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; @@ -230,7 +231,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 +241,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); } @@ -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."); } } diff --git a/common/src/main/resources/i18n/translations_de.properties b/common/src/main/resources/i18n/translations_de.properties index a3e4950c557..9475887ea92 100644 --- a/common/src/main/resources/i18n/translations_de.properties +++ b/common/src/main/resources/i18n/translations_de.properties @@ -118,6 +118,11 @@ 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. +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. role_conflicting_name=Rollenname bereits belegt. @@ -316,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 784e3f577d3..325c27ecd3e 100644 --- a/common/src/main/resources/i18n/translations_en.properties +++ b/common/src/main/resources/i18n/translations_en.properties @@ -117,6 +117,11 @@ 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. +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. role_conflicting_name=Role name is conflicting with an existing role. 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/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/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..abf094b4b6c --- /dev/null +++ b/connectors/common/src/main/resources/META-INF/liquibase/entries-3.3.x/1-features/changelog-gpu-2604.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..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 @@ -5,35 +5,46 @@ 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.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; 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; 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.gentics.mesh.util.ValidationUtil; /** * Handler which contains methods for user related requests. @@ -47,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; } /** @@ -139,26 +155,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)); } } @@ -166,25 +194,49 @@ 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)); } } + /** + * 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 583af106056..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 @@ -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; @@ -78,8 +79,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"); @@ -88,19 +91,37 @@ 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); 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 uuid = ac.getParameter("userUuid"); - crudHandler.handleDeleteAPIToken(ac, uuid); + 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, userExamples.getAPITokenListResponse("Some API Token"), "API token list response"); + 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/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/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 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/dao/APITokenDao.java b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java new file mode 100644 index 00000000000..88d487fa00f --- /dev/null +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/dao/APITokenDao.java @@ -0,0 +1,62 @@ +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 expiration 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 an API Token for a user by name + * @param user user + * @param name token name + * @return instance or null + */ + HibAPITokenData findByName(HibUser user, String name); + + /** + * 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); + + /** + * Delete the token + * @param token token + */ + void delete(HibAPITokenData token); +} 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..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 @@ -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. * @@ -119,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 new file mode 100644 index 00000000000..b51ba3f750e --- /dev/null +++ b/mdm/api/src/main/java/com/gentics/mesh/core/data/user/HibAPITokenData.java @@ -0,0 +1,130 @@ +package com.gentics.mesh.core.data.user; + +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.rest.user.UserAPITokenDataModel; +import com.gentics.mesh.util.DateUtils; + +/** + * Domain model for API Token Data + */ +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 + */ + 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/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/common/src/main/java/com/gentics/mesh/example/ExampleUuids.java b/mdm/common/src/main/java/com/gentics/mesh/example/ExampleUuids.java index f5d5a3b0bf3..bf4ed2d3906 100644 --- a/mdm/common/src/main/java/com/gentics/mesh/example/ExampleUuids.java +++ b/mdm/common/src/main/java/com/gentics/mesh/example/ExampleUuids.java @@ -97,6 +97,8 @@ public final class ExampleUuids { public static final String TOKEN_UUID = "30fa3e4ae00c4ad6ba3e4ae00cfad6cc"; + public static final String TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1XiJ9.eyJ1c2VyVXVpZCI6IjAxZjE4NjdlMjY4YTE4NmQ5NjU1YTRjMWM3NWJmNDFiIiwiaWF0IjoxNzg0NzkyMDUzLCJleHAiOjE3ODQ3OTU2NTN9.ONB6tUc-BbHs8JzbAQz6_rmFvYNLeAIQDwIbmf0vk78"; + // Plugins public static final String PLUGIN_1_ID = "hello-world"; 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 396fd95b777..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 @@ -6,13 +6,19 @@ import static com.gentics.mesh.core.rest.common.Permission.UPDATE; import static com.gentics.mesh.example.ExampleUuids.GROUP_EDITORS_UUID; import static com.gentics.mesh.example.ExampleUuids.NODE_DELOREAN_UUID; +import static com.gentics.mesh.example.ExampleUuids.TOKEN; import static com.gentics.mesh.example.ExampleUuids.TOKEN_UUID; import static com.gentics.mesh.example.ExampleUuids.USER_EDITOR_UUID; import static com.gentics.mesh.example.ExampleUuids.USER_WEBCLIENT_UUID; +import java.time.Instant; + import com.gentics.mesh.core.rest.group.GroupReference; import com.gentics.mesh.core.rest.user.ExpandableNode; 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; @@ -20,6 +26,7 @@ import com.gentics.mesh.core.rest.user.UserResetTokenResponse; import com.gentics.mesh.core.rest.user.UserResponse; import com.gentics.mesh.core.rest.user.UserUpdateRequest; +import com.gentics.mesh.util.DateUtils; public class UserExamples extends AbstractExamples { @@ -118,8 +125,32 @@ public UserResetTokenResponse getTokenResponse() { return new UserResetTokenResponse().setToken("FDrbBDWRY3aS").setCreated(createNewTimestamp()); } - public UserAPITokenResponse getAPIKeyResponse() { - return new UserAPITokenResponse().setToken(TOKEN_UUID); + public UserAPITokenDataModel getAPITokenData(String name) { + UserAPITokenDataModel dataModel = new UserAPITokenDataModel() + .setExpires(DateUtils.toISO8601(Instant.parse("2026-12-24T18:00:00.00Z").toEpochMilli())) + .setIssued(DateUtils.toISO8601(Instant.parse("2026-12-01T08:30:00.00Z").toEpochMilli())) + .setLastUsed(DateUtils.toISO8601(Instant.parse("2026-12-06T15:38:46.00Z").toEpochMilli())) + .setName(name) + .setValid(true); + dataModel.setUuid(TOKEN_UUID); + return dataModel; + } + + public UserAPITokenResponse getAPIKeyResponse(String name) { + return new UserAPITokenResponse() + .setToken(TOKEN) + .setData(getAPITokenData(name)); } + public UserAPITokenCreateRequest getAPITokenCreateRequest(String name) { + return new UserAPITokenCreateRequest() + .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; + } } 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..f32c641878e --- /dev/null +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/dao/APITokenDaoImpl.java @@ -0,0 +1,123 @@ +package com.gentics.mesh.hibernate.data.dao; + +import static com.gentics.mesh.core.rest.error.Errors.conflict; +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.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 extends AbstractHibDao implements APITokenDao { + protected final CurrentTransaction currentTransaction; + + protected final DaoHelper daoHelper; + + @Inject + 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; + } + + @Override + public UserAPITokenDataModel transformToRestSync(HibAPITokenData element, InternalActionContext ac, int level, + String... languageTags) { + UserAPITokenDataModel model = new UserAPITokenDataModel(); + model.setUuid(element.getUuid()); + model.setName(element.getName()); + model.setIssued(element.getIssuedDate()); + model.setLastUsed(element.getLastUsedDate()); + model.setExpires(element.getExpiresDate()); + model.setValid(element.isValid()); + return model; + } + + @Override + public HibAPITokenData create(HibUser user, String name, String tokenId, Long expires) { + HibAPITokenData conflicting = findByName(user, name); + if (conflicting != null) { + throw conflict(conflicting.getUuid(), name, "apitoken_conflicting_name"); + } + + 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) { + 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); + } + + @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 + 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)); + } + + @Override + 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/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..d73239bf310 --- /dev/null +++ b/mdm/hibernate-core/src/main/java/com/gentics/mesh/hibernate/data/domain/HibAPITokenDataImpl.java @@ -0,0 +1,118 @@ +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.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; +import jakarta.persistence.ManyToOne; + +/** + * Entity implementation of {@link HibAPITokenData} + */ +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "HibEntityCache") +@Entity(name = "apitoken") +@ElementTypeKey(ElementType.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/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/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-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/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 new file mode 100644 index 00000000000..a70eb35235e --- /dev/null +++ b/rest-model/src/main/java/com/gentics/mesh/core/rest/user/UserAPITokenDataModel.java @@ -0,0 +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/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/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/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/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..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,17 +5,21 @@ 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; 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; 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; @@ -48,7 +52,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 -> { @@ -99,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 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..668b008fd92 --- /dev/null +++ b/tests/tests-core/src/main/java/com/gentics/mesh/core/user/APITokenTest.java @@ -0,0 +1,266 @@ +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.Collection; +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.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; +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 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 + */ + @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 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 + */ + @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(); + for (HibUser user : otherUsers()) { + 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(); + 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 + */ + @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); + }); + } + + Set existing = new HashSet<>(); + existing.addAll(tx(tx -> { + InternalActionContext ac = mockActionContext(); + return tx.apiTokenDao().findAll(ac, user(), new PagingParametersImpl(1, 1000L)); + }).getWrappedList()); + + assertThat(existing).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/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 2e991e2854f..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 @@ -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; @@ -65,6 +69,9 @@ 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.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; @@ -83,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; @@ -297,14 +305,16 @@ 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(); + + UserAPITokenListResponse tokens = call(() -> client().findAPITokens(uuid)); + assertThat(tokens.getData()).containsOnly(response.getData()); - assertNotNull(tx(tx -> { return tx.userDao().findByUuid(uuid).getAPIKeyTokenCode(); })); client().setLogin(null, null); client().setAPIKey(response.getToken()); @@ -313,24 +323,73 @@ 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 + // new invalidate the token + call(() -> client().invalidateAPIToken(uuid, response.getData().getUuid())); call(() -> client().findUserByUuid(uuid), UNAUTHORIZED, "error_not_authorized"); + } - // Now set the active key and verify that the request works - client().setAPIKey(response.getToken()); + @Test + public void testAPITokenDuplicateName() { + String uuid = tx(() -> user().getUuid()); + String conflictingName = "Conflicting Name"; + call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName(conflictingName))); - call(() -> client().findUserByUuid(uuid)); + call(() -> client().issueAPIToken(uuid, new UserAPITokenCreateRequest().setName(conflictingName)), CONFLICT, + "apitoken_conflicting_name"); + } - call(() -> client().invalidateAPIToken(uuid)); - 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"); + @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 @@ -342,7 +401,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 +409,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 +418,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 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(); + } + } + } +}