From: Dimitris Kavvathas
Date: Tue, 5 Jul 2022 13:17:39 +0000 (+0200)
Subject: SONAR-16566 Enforce max token lifespan on `api/user_tokens/generate` API call
X-Git-Tag: 9.6.0.59041~276
X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=7c57505bae05fd5d50eabb5ee2b2372a58530cef;p=sonarqube.git
SONAR-16566 Enforce max token lifespan on `api/user_tokens/generate` API call
---
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java
index 901a991dd7d..95ed509eb32 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java
@@ -24,7 +24,7 @@ import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;
-import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
@@ -40,13 +40,13 @@ import org.sonar.server.usertoken.TokenGenerator;
import org.sonarqube.ws.UserTokens;
import org.sonarqube.ws.UserTokens.GenerateWsResponse;
-import static com.google.common.base.Preconditions.checkArgument;
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
import static org.sonar.api.utils.DateUtils.formatDateTime;
import static org.sonar.db.user.TokenType.GLOBAL_ANALYSIS_TOKEN;
import static org.sonar.db.user.TokenType.PROJECT_ANALYSIS_TOKEN;
import static org.sonar.db.user.TokenType.USER_TOKEN;
import static org.sonar.server.exceptions.BadRequestException.checkRequest;
+import static org.sonar.server.usertoken.ws.GenerateActionValidation.validateParametersCombination;
import static org.sonar.server.usertoken.ws.UserTokenSupport.ACTION_GENERATE;
import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_EXPIRATION_DATE;
import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN;
@@ -63,12 +63,14 @@ public class GenerateAction implements UserTokensWsAction {
private final System2 system;
private final TokenGenerator tokenGenerator;
private final UserTokenSupport userTokenSupport;
+ private final GenerateActionValidation validation;
- public GenerateAction(DbClient dbClient, System2 system, TokenGenerator tokenGenerator, UserTokenSupport userTokenSupport) {
+ public GenerateAction(DbClient dbClient, System2 system, TokenGenerator tokenGenerator, UserTokenSupport userTokenSupport, GenerateActionValidation validation) {
this.dbClient = dbClient;
this.system = system;
this.tokenGenerator = tokenGenerator;
this.userTokenSupport = userTokenSupport;
+ this.validation = validation;
}
@Override
@@ -133,73 +135,43 @@ public class GenerateAction implements UserTokensWsAction {
}
private UserTokenDto getUserTokenDtoFromRequest(Request request) {
+ LocalDate expirationDate = getExpirationDateFromRequest(request);
+ validation.validateExpirationDate(expirationDate);
+
UserTokenDto userTokenDtoFromRequest = new UserTokenDto()
.setName(request.mandatoryParam(PARAM_NAME).trim())
.setCreatedAt(system.now())
- .setType(getTokenTypeFromRequest(request).name())
- .setExpirationDate(getExpirationDateFromRequest(request));
-
+ .setType(getTokenTypeFromRequest(request).name());
+ if (expirationDate != null) {
+ userTokenDtoFromRequest.setExpirationDate(expirationDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli());
+ }
getProjectKeyFromRequest(request).ifPresent(userTokenDtoFromRequest::setProjectKey);
-
return userTokenDtoFromRequest;
}
- private static Long getExpirationDateFromRequest(Request request) {
+ @Nullable
+ private static LocalDate getExpirationDateFromRequest(Request request) {
String expirationDateString = request.param(PARAM_EXPIRATION_DATE);
- Long expirationDateOpt = null;
if (expirationDateString != null) {
try {
- expirationDateOpt = getExpirationDateFromString(expirationDateString);
+ return LocalDate.parse(expirationDateString, DateTimeFormatter.ISO_DATE);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(String.format("Supplied date format for parameter %s is wrong. Please supply date in the ISO 8601 " +
"date format (YYYY-MM-DD)", PARAM_EXPIRATION_DATE));
}
}
- return expirationDateOpt;
- }
-
- @NotNull
- private static Long getExpirationDateFromString(String expirationDateString) {
- LocalDate expirationDate = LocalDate.parse(expirationDateString, DateTimeFormatter.ISO_DATE);
- validateExpirationDateValue(expirationDate);
- return expirationDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli();
- }
-
- private static void validateExpirationDateValue(LocalDate localDate) {
- if (localDate.isBefore(LocalDate.now().plusDays(1))) {
- throw new IllegalArgumentException(
- String.format("The minimum value for parameter %s is %s.", PARAM_EXPIRATION_DATE, LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_DATE)));
- }
+ return null;
}
private String generateToken(Request request, DbSession dbSession) {
TokenType tokenType = getTokenTypeFromRequest(request);
- validateParametersCombination(dbSession, request, tokenType);
+ validateParametersCombination(userTokenSupport, dbSession, request, tokenType);
return tokenGenerator.generate(tokenType);
}
- private void validateParametersCombination(DbSession dbSession, Request request, TokenType tokenType) {
- if (PROJECT_ANALYSIS_TOKEN.equals(tokenType)) {
- validateProjectAnalysisParameters(dbSession, request);
- } else if (GLOBAL_ANALYSIS_TOKEN.equals(tokenType)) {
- validateGlobalAnalysisParameters(request);
- }
- }
-
- private void validateProjectAnalysisParameters(DbSession dbSession, Request request) {
- checkArgument(userTokenSupport.sameLoginAsConnectedUser(request), "A Project Analysis Token cannot be generated for another user.");
- checkArgument(request.param(PARAM_PROJECT_KEY) != null, "A projectKey is needed when creating Project Analysis Token");
- userTokenSupport.validateProjectScanPermission(dbSession, getProjectKeyFromRequest(request).orElse(""));
- }
-
- private void validateGlobalAnalysisParameters(Request request) {
- checkArgument(userTokenSupport.sameLoginAsConnectedUser(request), "A Global Analysis Token cannot be generated for another user.");
- userTokenSupport.validateGlobalScanPermission();
- }
-
- private static Optional getProjectKeyFromRequest(Request request) {
+ public static Optional getProjectKeyFromRequest(Request request) {
String projectKey = null;
if (PROJECT_ANALYSIS_TOKEN.equals(getTokenTypeFromRequest(request))) {
projectKey = request.mandatoryParam(PARAM_PROJECT_KEY).trim();
@@ -221,7 +193,7 @@ public class GenerateAction implements UserTokensWsAction {
throw new ServerException(HTTP_INTERNAL_ERROR, "Error while generating token. Please try again.");
}
- private UserTokenDto insertTokenInDb(DbSession dbSession, UserDto user,UserTokenDto userTokenDto) {
+ private UserTokenDto insertTokenInDb(DbSession dbSession, UserDto user, UserTokenDto userTokenDto) {
checkTokenDoesNotAlreadyExists(dbSession, user, userTokenDto.getName());
dbClient.userTokenDao().insert(dbSession, userTokenDto, user.getLogin());
dbSession.commit();
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateActionValidation.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateActionValidation.java
new file mode 100644
index 00000000000..4086c2a2736
--- /dev/null
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateActionValidation.java
@@ -0,0 +1,136 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.usertoken.ws;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import org.jetbrains.annotations.Nullable;
+import org.sonar.api.SonarRuntime;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.server.ws.Request;
+import org.sonar.core.config.MaxTokenLifetimeOption;
+import org.sonar.db.DbSession;
+import org.sonar.db.user.TokenType;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.sonar.api.SonarEdition.COMMUNITY;
+import static org.sonar.api.SonarEdition.DEVELOPER;
+import static org.sonar.core.config.MaxTokenLifetimeOption.NO_EXPIRATION;
+import static org.sonar.core.config.TokenExpirationConstants.MAX_ALLOWED_TOKEN_LIFETIME;
+import static org.sonar.db.user.TokenType.GLOBAL_ANALYSIS_TOKEN;
+import static org.sonar.db.user.TokenType.PROJECT_ANALYSIS_TOKEN;
+import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_EXPIRATION_DATE;
+import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_PROJECT_KEY;
+
+public final class GenerateActionValidation {
+
+ private final Configuration configuration;
+ private final SonarRuntime sonarRuntime;
+
+ public GenerateActionValidation(Configuration configuration, SonarRuntime sonarRuntime) {
+ this.configuration = configuration;
+ this.sonarRuntime = sonarRuntime;
+ }
+
+ /**
+ * Returns the max allowed token lifetime property based on the Sonar Edition.
+ *
+ *
+ * - COMMUNITY and DEVELOPER editions don't allow the selection of a max token lifetime, therefore it always defaults to NO_EXPIRATION
+ * - ENTERPRISE and DATACENTER editions support the selection of max token lifetime property and the value is searched in the enum
+ *
+ *
+ * @return The max allowed token lifetime.
+ */
+ public MaxTokenLifetimeOption getMaxTokenLifetimeOption() {
+ if (List.of(COMMUNITY, DEVELOPER).contains(sonarRuntime.getEdition())) {
+ return NO_EXPIRATION;
+ }
+
+ String maxTokenLifetimeProp = configuration.get(MAX_ALLOWED_TOKEN_LIFETIME).orElse(NO_EXPIRATION.getName());
+ return MaxTokenLifetimeOption.get(maxTokenLifetimeProp);
+ }
+
+ /**
+ * Validates if the expiration date of the token is between the minimum and maximum allowed values.
+ *
+ * @param expirationDate The expiration date
+ */
+ void validateExpirationDate(@Nullable LocalDate expirationDate) {
+ MaxTokenLifetimeOption maxTokenLifetime = getMaxTokenLifetimeOption();
+ if (expirationDate != null) {
+ validateMinExpirationDate(expirationDate);
+ validateMaxExpirationDate(maxTokenLifetime, expirationDate);
+ } else {
+ validateMaxExpirationDate(maxTokenLifetime);
+ }
+ }
+
+ static void validateMaxExpirationDate(MaxTokenLifetimeOption maxTokenLifetime, LocalDate expirationDate) {
+ maxTokenLifetime.getDays()
+ .ifPresent(days -> compareExpirationDateToMaxAllowedLifetime(expirationDate, LocalDate.now().plusDays(days)));
+ }
+
+ static void validateMaxExpirationDate(MaxTokenLifetimeOption maxTokenLifetime) {
+ maxTokenLifetime.getDays()
+ .ifPresent(days -> {
+ throw new IllegalArgumentException(
+ String.format("Tokens expiring after %s are not allowed. Please use an expiration date.",
+ LocalDate.now().plusDays(days).format(DateTimeFormatter.ISO_DATE)));
+ });
+ }
+
+ static void compareExpirationDateToMaxAllowedLifetime(LocalDate expirationDate, LocalDate maxExpirationDate) {
+ if (expirationDate.isAfter(maxExpirationDate)) {
+ throw new IllegalArgumentException(
+ String.format("Tokens expiring after %s are not allowed. Please use a valid expiration date.",
+ maxExpirationDate.format(DateTimeFormatter.ISO_DATE)));
+ }
+ }
+
+ static void validateMinExpirationDate(LocalDate localDate) {
+ if (localDate.isBefore(LocalDate.now().plusDays(1))) {
+ throw new IllegalArgumentException(
+ String.format("The minimum value for parameter %s is %s.", PARAM_EXPIRATION_DATE, LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_DATE)));
+ }
+ }
+
+ static void validateParametersCombination(UserTokenSupport userTokenSupport, DbSession dbSession, Request request, TokenType tokenType) {
+ if (PROJECT_ANALYSIS_TOKEN.equals(tokenType)) {
+ validateProjectAnalysisParameters(userTokenSupport, dbSession, request);
+ } else if (GLOBAL_ANALYSIS_TOKEN.equals(tokenType)) {
+ validateGlobalAnalysisParameters(userTokenSupport, request);
+ }
+ }
+
+ private static void validateProjectAnalysisParameters(UserTokenSupport userTokenSupport, DbSession dbSession, Request request) {
+ checkArgument(userTokenSupport.sameLoginAsConnectedUser(request), "A Project Analysis Token cannot be generated for another user.");
+ checkArgument(request.param(PARAM_PROJECT_KEY) != null, "A projectKey is needed when creating Project Analysis Token");
+ userTokenSupport.validateProjectScanPermission(dbSession, request.param(PARAM_PROJECT_KEY));
+ }
+
+ private static void validateGlobalAnalysisParameters(UserTokenSupport userTokenSupport, Request request) {
+ checkArgument(userTokenSupport.sameLoginAsConnectedUser(request), "A Global Analysis Token cannot be generated for another user.");
+ userTokenSupport.validateGlobalScanPermission();
+ }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenSupport.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenSupport.java
index 2e0a64fbca7..b42b25b3d21 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenSupport.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenSupport.java
@@ -88,10 +88,10 @@ public class UserTokenSupport {
throw insufficientPrivilegesException();
}
- public void validateProjectScanPermission(DbSession dbSession, String projecKeyFromRequest) {
- Optional projectDto = dbClient.projectDao().selectProjectByKey(dbSession, projecKeyFromRequest);
+ public void validateProjectScanPermission(DbSession dbSession, String projectKeyFromRequest) {
+ Optional projectDto = dbClient.projectDao().selectProjectByKey(dbSession, projectKeyFromRequest);
if (projectDto.isEmpty()) {
- throw new NotFoundException(format("Project key '%s' not found", projecKeyFromRequest));
+ throw new NotFoundException(format("Project key '%s' not found", projectKeyFromRequest));
}
validateProjectScanPermission(projectDto.get());
}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenWsModule.java
index 1fd9f792f1e..81552eabd00 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenWsModule.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenWsModule.java
@@ -29,8 +29,8 @@ public class UserTokenWsModule extends Module {
UserTokenSupport.class,
GenerateAction.class,
RevokeAction.class,
- SearchAction.class
-
+ SearchAction.class,
+ GenerateActionValidation.class
);
}
}
diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/usertoken/ws/search-example.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/usertoken/ws/search-example.json
index 4279e2891be..55cbb9538e8 100644
--- a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/usertoken/ws/search-example.json
+++ b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/usertoken/ws/search-example.json
@@ -9,7 +9,8 @@
{
"name": "Project scan on Jenkins",
"createdAt": "2015-04-08T21:57:47+0200",
- "expirationDate": "2022-07-14T00:00:00+0200",
+ "expirationDate": "2019-07-14T00:00:00+0200",
+ "isExpired": true,
"type": "PROJECT_ANALYSIS_TOKEN",
"project": {
"key": "project-1",
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java
index 39cab8b1ce4..3b8e5cdb2d2 100644
--- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java
@@ -22,12 +22,15 @@ package org.sonar.server.usertoken.ws;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
+import java.util.List;
import javax.annotation.Nullable;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
+import org.sonar.api.SonarRuntime;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.config.internal.MapSettings;
import org.sonar.api.server.ws.WebService;
import org.sonar.api.utils.System2;
import org.sonar.db.DbTester;
@@ -51,8 +54,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import static org.sonar.api.SonarEdition.COMMUNITY;
+import static org.sonar.api.SonarEdition.DATACENTER;
+import static org.sonar.api.SonarEdition.DEVELOPER;
+import static org.sonar.api.SonarEdition.ENTERPRISE;
import static org.sonar.api.utils.DateUtils.DATETIME_FORMAT;
import static org.sonar.api.utils.DateUtils.DATE_FORMAT;
+import static org.sonar.core.config.MaxTokenLifetimeOption.NO_EXPIRATION;
+import static org.sonar.core.config.MaxTokenLifetimeOption.THIRTY_DAYS;
+import static org.sonar.core.config.TokenExpirationConstants.MAX_ALLOWED_TOKEN_LIFETIME;
import static org.sonar.db.permission.GlobalPermission.SCAN;
import static org.sonar.db.user.TokenType.GLOBAL_ANALYSIS_TOKEN;
import static org.sonar.db.user.TokenType.PROJECT_ANALYSIS_TOKEN;
@@ -73,10 +83,14 @@ public class GenerateActionTest {
@Rule
public UserSessionRule userSession = UserSessionRule.standalone();
+ private final SonarRuntime runtime = mock(SonarRuntime.class);
private final TokenGenerator tokenGenerator = mock(TokenGenerator.class);
+ private final MapSettings mapSettings = new MapSettings();
+ private final Configuration configuration = mapSettings.asConfig();
+ private final GenerateActionValidation validation = new GenerateActionValidation(configuration, runtime);
private final WsActionTester ws = new WsActionTester(
- new GenerateAction(db.getDbClient(), System2.INSTANCE, tokenGenerator, new UserTokenSupport(db.getDbClient(), userSession)));
+ new GenerateAction(db.getDbClient(), System2.INSTANCE, tokenGenerator, new UserTokenSupport(db.getDbClient(), userSession), validation));
@Before
public void setUp() {
@@ -84,6 +98,7 @@ public class GenerateActionTest {
when(tokenGenerator.generate(GLOBAL_ANALYSIS_TOKEN)).thenReturn("sqa_123456789");
when(tokenGenerator.generate(PROJECT_ANALYSIS_TOKEN)).thenReturn("sqp_123456789");
when(tokenGenerator.hash(anyString())).thenReturn("987654321");
+ when(runtime.getEdition()).thenReturn(ENTERPRISE); // by default, a Sonar version that supports the max allowed lifetime token property
}
@Test
@@ -120,8 +135,7 @@ public class GenerateActionTest {
@Test
public void a_user_can_generate_token_for_himself() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ UserDto user = userLogin();
GenerateWsResponse response = newRequest(null, TOKEN_NAME);
@@ -131,8 +145,7 @@ public class GenerateActionTest {
@Test
public void a_user_can_generate_globalAnalysisToken_with_the_global_scan_permission() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ UserDto user = userLogin();
userSession.addPermission(SCAN);
GenerateWsResponse response = newRequest(null, TOKEN_NAME, GLOBAL_ANALYSIS_TOKEN, null);
@@ -144,9 +157,8 @@ public class GenerateActionTest {
@Test
public void a_user_can_generate_projectAnalysisToken_with_the_project_global_scan_permission() {
- UserDto user = db.users().insertUser();
+ UserDto user = userLogin();
ComponentDto project = db.components().insertPublicProject();
- userSession.logIn(user);
userSession.addPermission(SCAN);
GenerateWsResponse response = newRequest(null, TOKEN_NAME, PROJECT_ANALYSIS_TOKEN, project.getKey());
@@ -159,9 +171,8 @@ public class GenerateActionTest {
@Test
public void a_user_can_generate_projectAnalysisToken_with_the_project_scan_permission() {
- UserDto user = db.users().insertUser();
+ UserDto user = userLogin();
ComponentDto project = db.components().insertPublicProject();
- userSession.logIn(user);
userSession.addProjectPermission(SCAN.toString(), project);
GenerateWsResponse response = newRequest(null, TOKEN_NAME, PROJECT_ANALYSIS_TOKEN, project.getKey());
@@ -174,9 +185,8 @@ public class GenerateActionTest {
@Test
public void a_user_can_generate_projectAnalysisToken_with_the_project_scan_permission_passing_login() {
- UserDto user = db.users().insertUser();
+ UserDto user = userLogin();
ComponentDto project = db.components().insertPublicProject();
- userSession.logIn(user);
userSession.addProjectPermission(SCAN.toString(), project);
GenerateWsResponse responseWithLogin = newRequest(user.getLogin(), TOKEN_NAME, PROJECT_ANALYSIS_TOKEN, project.getKey());
@@ -189,8 +199,7 @@ public class GenerateActionTest {
@Test
public void a_user_can_generate_token_for_himself_with_expiration_date() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ UserDto user = userLogin();
// A date 10 days in the future with format yyyy-MM-dd
String expirationDateValue = LocalDate.now().plusDays(10).format(DateTimeFormatter.ofPattern(DATE_FORMAT));
@@ -204,7 +213,7 @@ public class GenerateActionTest {
@Test
public void an_administrator_can_generate_token_for_users_with_expiration_date() {
- UserDto user = db.users().insertUser();
+ UserDto user = userLogin();
logInAsSystemAdministrator();
// A date 10 days in the future with format yyyy-MM-dd
@@ -228,7 +237,7 @@ public class GenerateActionTest {
@Test
public void fail_if_name_is_blank() {
- UserDto user = db.users().insertUser();
+ UserDto user = userLogin();
logInAsSystemAdministrator();
String login = user.getLogin();
@@ -240,8 +249,7 @@ public class GenerateActionTest {
@Test
public void fail_if_globalAnalysisToken_created_for_other_user() {
- UserDto user = db.users().insertUser();
- String login = user.getLogin();
+ String login = userLogin().getLogin();
logInAsSystemAdministrator();
assertThatThrownBy(() -> newRequest(login, "token 1", GLOBAL_ANALYSIS_TOKEN, null))
@@ -251,8 +259,7 @@ public class GenerateActionTest {
@Test
public void fail_if_projectAnalysisToken_created_for_other_user() {
- UserDto user = db.users().insertUser();
- String login = user.getLogin();
+ String login = userLogin().getLogin();
logInAsSystemAdministrator();
assertThatThrownBy(() -> newRequest(login, "token 1", PROJECT_ANALYSIS_TOKEN, "project 1"))
@@ -262,8 +269,7 @@ public class GenerateActionTest {
@Test
public void fail_if_globalAnalysisToken_created_without_global_permission() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ userLogin();
assertThatThrownBy(() -> {
newRequest(null, "token 1", GLOBAL_ANALYSIS_TOKEN, null);
@@ -274,8 +280,7 @@ public class GenerateActionTest {
@Test
public void fail_if_projectAnalysisToken_created_without_project_permission() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ userLogin();
String projectKey = db.components().insertPublicProject().getKey();
assertThatThrownBy(() -> newRequest(null, "token 1", PROJECT_ANALYSIS_TOKEN, projectKey))
@@ -285,8 +290,7 @@ public class GenerateActionTest {
@Test
public void fail_if_projectAnalysisToken_created_for_blank_projectKey() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ userLogin();
assertThatThrownBy(() -> {
newRequest(null, "token 1", PROJECT_ANALYSIS_TOKEN, null);
@@ -297,8 +301,7 @@ public class GenerateActionTest {
@Test
public void fail_if_projectAnalysisToken_created_for_non_existing_project() {
- UserDto user = db.users().insertUser();
- userSession.logIn(user);
+ userLogin();
userSession.addPermission(SCAN);
assertThatThrownBy(() -> {
@@ -348,6 +351,117 @@ public class GenerateActionTest {
.hasMessage(String.format("The minimum value for parameter %s is %s.", PARAM_EXPIRATION_DATE, LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_DATE)));
}
+ @Test
+ public void success_if_expirationDate_is_equal_to_the_max_allowed_token_lifetime() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, THIRTY_DAYS.getName());
+ String expirationDateString = LocalDate.now().plusDays(30).format(DateTimeFormatter.ofPattern(DATE_FORMAT));
+
+ GenerateWsResponse response = newRequest(login, TOKEN_NAME, expirationDateString);
+ assertThat(response.getLogin()).isEqualTo(login);
+ assertThat(response.getCreatedAt()).isNotEmpty();
+ assertThat(response.getExpirationDate()).isEqualTo(getFormattedDate(expirationDateString));
+ }
+
+ @Test
+ public void success_if_expirationDate_is_before_the_max_allowed_token_lifetime() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, THIRTY_DAYS.getName());
+ String expirationDateString = LocalDate.now().plusDays(29).format(DateTimeFormatter.ofPattern(DATE_FORMAT));
+
+ GenerateWsResponse response = newRequest(login, TOKEN_NAME, expirationDateString);
+ assertThat(response.getLogin()).isEqualTo(login);
+ assertThat(response.getCreatedAt()).isNotEmpty();
+ assertThat(response.getExpirationDate()).isEqualTo(getFormattedDate(expirationDateString));
+ }
+
+ @Test
+ public void success_if_no_expiration_date_is_allowed_with_expiration_date() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, NO_EXPIRATION.getName());
+ String expirationDateString = LocalDate.now().plusDays(30).format(DateTimeFormatter.ofPattern(DATE_FORMAT));
+
+ GenerateWsResponse response = newRequest(login, TOKEN_NAME, expirationDateString);
+ assertThat(response.getLogin()).isEqualTo(login);
+ assertThat(response.getCreatedAt()).isNotEmpty();
+ assertThat(response.getExpirationDate()).isEqualTo(getFormattedDate(expirationDateString));
+ }
+
+ @Test
+ public void success_if_no_expiration_date_is_allowed_without_expiration_date() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, NO_EXPIRATION.getName());
+
+ GenerateWsResponse response = newRequest(login, TOKEN_NAME);
+ assertThat(response.getLogin()).isEqualTo(login);
+ assertThat(response.getCreatedAt()).isNotEmpty();
+ }
+
+ @Test
+ public void fail_if_expirationDate_is_after_the_max_allowed_token_lifetime() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, THIRTY_DAYS.getName());
+
+ String expirationDateString = LocalDate.now().plusDays(31).format(DateTimeFormatter.ofPattern(DATE_FORMAT));
+
+ // with expiration date
+ assertThatThrownBy(() -> {
+ newRequest(login, TOKEN_NAME, expirationDateString);
+ })
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Tokens expiring after %s are not allowed. Please use a valid expiration date.",
+ LocalDate.now().plusDays(THIRTY_DAYS.getDays().get()).format(DateTimeFormatter.ISO_DATE));
+
+ // without expiration date
+ when(tokenGenerator.hash(anyString())).thenReturn("random");
+ assertThatThrownBy(() -> {
+ newRequest(login, TOKEN_NAME);
+ })
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Tokens expiring after %s are not allowed. Please use an expiration date.",
+ LocalDate.now().plusDays(THIRTY_DAYS.getDays().get()).format(DateTimeFormatter.ISO_DATE));
+ }
+
+ @Test
+ public void max_allowed_token_lifetime_not_enforced_for_unsupported_versions() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, THIRTY_DAYS.getName());
+
+ List.of(DEVELOPER, COMMUNITY).forEach(edition -> {
+ when(runtime.getEdition()).thenReturn(edition);
+ when(tokenGenerator.hash(anyString())).thenReturn("987654321" + edition);
+
+ GenerateWsResponse response = newRequest(login, TOKEN_NAME + edition);
+ assertThat(response.getLogin()).isEqualTo(login);
+ assertThat(response.getCreatedAt()).isNotEmpty();
+ });
+ }
+
+ @Test
+ public void max_allowed_token_lifetime_enforced_for_supported_versions() {
+ String login = userLogin().getLogin();
+
+ mapSettings.setProperty(MAX_ALLOWED_TOKEN_LIFETIME, THIRTY_DAYS.getName());
+
+ List.of(ENTERPRISE, DATACENTER).forEach(edition -> {
+ when(runtime.getEdition()).thenReturn(edition);
+ when(tokenGenerator.hash(anyString())).thenReturn("987654321" + edition);
+
+ assertThatThrownBy(() -> {
+ newRequest(login, TOKEN_NAME);
+ })
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Tokens expiring after %s are not allowed. Please use an expiration date.",
+ LocalDate.now().plusDays(THIRTY_DAYS.getDays().get()).format(DateTimeFormatter.ISO_DATE));
+ });
+ }
+
@Test
public void fail_if_token_hash_already_exists_in_db() {
UserDto user = db.users().insertUser();
@@ -420,6 +534,12 @@ public class GenerateActionTest {
return testRequest.executeProtobuf(GenerateWsResponse.class);
}
+ private UserDto userLogin() {
+ UserDto user = db.users().insertUser();
+ userSession.logIn(user);
+ return user;
+ }
+
private void logInAsSystemAdministrator() {
userSession.logIn().setSystemAdministrator();
}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java
index 758033f5834..7bdfa6ebc00 100644
--- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java
@@ -75,7 +75,7 @@ public class SearchActionTest {
db.users().insertToken(user1, t -> t.setName("Project scan on AppVeyor").setCreatedAt(1438523067221L));
db.users().insertProjectAnalysisToken(user1, t -> t.setName("Project scan on Jenkins")
.setCreatedAt(1428523067221L)
- .setExpirationDate(1657749600000L)
+ .setExpirationDate(1563055200000L)
.setProjectKey(project1.getKey()));
db.users().insertProjectAnalysisToken(user2, t -> t.setName("Project scan on Travis")
.setCreatedAt(141456787123L)
diff --git a/sonar-core/src/main/java/org/sonar/core/config/MaxTokenLifetimeOption.java b/sonar-core/src/main/java/org/sonar/core/config/MaxTokenLifetimeOption.java
new file mode 100644
index 00000000000..0cca7d88035
--- /dev/null
+++ b/sonar-core/src/main/java/org/sonar/core/config/MaxTokenLifetimeOption.java
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.core.config;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import static java.util.function.UnaryOperator.identity;
+import static java.util.stream.Collectors.toMap;
+
+public enum MaxTokenLifetimeOption {
+ THIRTY_DAYS("30 days", 30),
+ NINETY_DAYS("90 days", 90),
+ ONE_YEAR("1 year", 365),
+ NO_EXPIRATION("No expiration");
+
+ private static final Map INTERVALS_MAP;
+
+ static {
+ INTERVALS_MAP = Stream.of(MaxTokenLifetimeOption.values())
+ .collect(toMap(MaxTokenLifetimeOption::getName, identity()));
+ }
+
+ private final String name;
+ private final Integer days;
+
+ MaxTokenLifetimeOption(String name) {
+ this.name = name;
+ this.days = null;
+ }
+
+ MaxTokenLifetimeOption(String name, Integer days) {
+ this.name = name;
+ this.days = days;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Optional getDays() {
+ return Optional.ofNullable(days);
+ }
+
+ public static MaxTokenLifetimeOption get(String name) {
+ return Optional.ofNullable(INTERVALS_MAP.get(name))
+ .orElseThrow(() -> new IllegalArgumentException("No token expiration interval with name \"" + name + "\" found."));
+ }
+
+}
diff --git a/sonar-core/src/main/java/org/sonar/core/config/TokenExpirationConstants.java b/sonar-core/src/main/java/org/sonar/core/config/TokenExpirationConstants.java
new file mode 100644
index 00000000000..67ecae4547c
--- /dev/null
+++ b/sonar-core/src/main/java/org/sonar/core/config/TokenExpirationConstants.java
@@ -0,0 +1,29 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.core.config;
+
+public final class TokenExpirationConstants {
+ public static final String MAX_ALLOWED_TOKEN_LIFETIME = "sonar.auth.token.max.allowed.lifetime";
+
+ private TokenExpirationConstants() {
+ }
+
+}
diff --git a/sonar-core/src/test/java/org/sonar/core/config/MaxTokenLifetimeOptionTest.java b/sonar-core/src/test/java/org/sonar/core/config/MaxTokenLifetimeOptionTest.java
new file mode 100644
index 00000000000..685d19f88c8
--- /dev/null
+++ b/sonar-core/src/test/java/org/sonar/core/config/MaxTokenLifetimeOptionTest.java
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.core.config;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.sonar.core.config.MaxTokenLifetimeOption.NINETY_DAYS;
+import static org.sonar.core.config.MaxTokenLifetimeOption.NO_EXPIRATION;
+import static org.sonar.core.config.MaxTokenLifetimeOption.ONE_YEAR;
+import static org.sonar.core.config.MaxTokenLifetimeOption.THIRTY_DAYS;
+
+public class MaxTokenLifetimeOptionTest {
+
+ @Test
+ public void all_options_present() {
+ assertThat(MaxTokenLifetimeOption.values()).hasSize(4);
+ }
+
+ @Test
+ public void when_get_by_name_then_the_enum_value_is_returned() {
+ assertThat(MaxTokenLifetimeOption.get("30 days")).isEqualTo(THIRTY_DAYS);
+ assertThat(MaxTokenLifetimeOption.get("90 days")).isEqualTo(NINETY_DAYS);
+ assertThat(MaxTokenLifetimeOption.get("1 year")).isEqualTo(ONE_YEAR);
+ assertThat(MaxTokenLifetimeOption.get("No expiration")).isEqualTo(NO_EXPIRATION);
+ }
+
+ @Test
+ public void when_get_by_name_nonexistant_then_exception_is_thrown() {
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(() -> MaxTokenLifetimeOption.get("wrong lifetime"))
+ .withMessage("No token expiration interval with name \"wrong lifetime\" found.");
+ }
+
+ @Test
+ public void lifetime_options_days() {
+ assertThat(THIRTY_DAYS.getDays()).hasValue(30);
+ assertThat(NINETY_DAYS.getDays()).hasValue(90);
+ assertThat(ONE_YEAR.getDays()).hasValue(365);
+ assertThat(NO_EXPIRATION.getDays()).isEmpty();
+ }
+}