]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16566 Enforce max token lifespan on `api/user_tokens/generate` API call
authorDimitris Kavvathas <dimitris.kavvathas@sonarsource.com>
Tue, 5 Jul 2022 13:17:39 +0000 (15:17 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 7 Jul 2022 20:03:11 +0000 (20:03 +0000)
server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateActionValidation.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenSupport.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenWsModule.java
server/sonar-webserver-webapi/src/main/resources/org/sonar/server/usertoken/ws/search-example.json
server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java
sonar-core/src/main/java/org/sonar/core/config/MaxTokenLifetimeOption.java [new file with mode: 0644]
sonar-core/src/main/java/org/sonar/core/config/TokenExpirationConstants.java [new file with mode: 0644]
sonar-core/src/test/java/org/sonar/core/config/MaxTokenLifetimeOptionTest.java [new file with mode: 0644]

index 901a991dd7d0946021be4c004e62c033eebd3bb3..95ed509eb323e3a3d55d09ca5b0bb14c9cfc1a71 100644 (file)
@@ -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<String> getProjectKeyFromRequest(Request request) {
+  public static Optional<String> 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 (file)
index 0000000..4086c2a
--- /dev/null
@@ -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;
+  }
+
+  /**
+   * <p>Returns the max allowed token lifetime property based on the Sonar Edition.</p>
+   * <p>
+   *   <ul>
+   *     <li>COMMUNITY and DEVELOPER editions don't allow the selection of a max token lifetime, therefore it always defaults to NO_EXPIRATION</li>
+   *     <li>ENTERPRISE and DATACENTER editions support the selection of max token lifetime property and the value is searched in the enum</li>
+   *   </ul>
+   * </p>
+   * @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);
+  }
+
+  /**
+   * <p>Validates if the expiration date of the token is between the minimum and maximum allowed values.</p>
+   *
+   * @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();
+  }
+
+}
index 2e0a64fbca73351301e40f6e5de839a21781472e..b42b25b3d211e35bc930bec981a7d1ef3dd246a0 100644 (file)
@@ -88,10 +88,10 @@ public class UserTokenSupport {
     throw insufficientPrivilegesException();
   }
 
-  public void validateProjectScanPermission(DbSession dbSession, String projecKeyFromRequest) {
-    Optional<ProjectDto> projectDto = dbClient.projectDao().selectProjectByKey(dbSession, projecKeyFromRequest);
+  public void validateProjectScanPermission(DbSession dbSession, String projectKeyFromRequest) {
+    Optional<ProjectDto> 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());
   }
index 1fd9f792f1ebb5e9c11133d2704bd2ee2387ad51..81552eabd000873f02ec1fdd16cd70715ae52b5d 100644 (file)
@@ -29,8 +29,8 @@ public class UserTokenWsModule extends Module {
       UserTokenSupport.class,
       GenerateAction.class,
       RevokeAction.class,
-      SearchAction.class
-
+      SearchAction.class,
+      GenerateActionValidation.class
     );
   }
 }
index 4279e2891be5eb315d05fb66cd620d5207c6351b..55cbb9538e8e5457161ad8244e8c8c8698dfec26 100644 (file)
@@ -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",
index 39cab8b1ce420cc6f5f420129b3d56032d743c13..3b8e5cdb2d298d87102dd6606355e9586ae19c55 100644 (file)
@@ -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();
   }
index 758033f5834f90bcabbe2341d9c76b81995ce0d6..7bdfa6ebc003c1f96f0af4f50df29f7d874d036d 100644 (file)
@@ -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 (file)
index 0000000..0cca7d8
--- /dev/null
@@ -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<String, MaxTokenLifetimeOption> 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<Integer> 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 (file)
index 0000000..67ecae4
--- /dev/null
@@ -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 (file)
index 0000000..685d19f
--- /dev/null
@@ -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();
+  }
+}