From 7c57505bae05fd5d50eabb5ee2b2372a58530cef Mon Sep 17 00:00:00 2001 From: Dimitris Kavvathas Date: Tue, 5 Jul 2022 15:17:39 +0200 Subject: [PATCH] SONAR-16566 Enforce max token lifespan on `api/user_tokens/generate` API call --- .../server/usertoken/ws/GenerateAction.java | 66 ++----- .../ws/GenerateActionValidation.java | 136 ++++++++++++++ .../server/usertoken/ws/UserTokenSupport.java | 6 +- .../usertoken/ws/UserTokenWsModule.java | 4 +- .../server/usertoken/ws/search-example.json | 3 +- .../usertoken/ws/GenerateActionTest.java | 176 +++++++++++++++--- .../server/usertoken/ws/SearchActionTest.java | 2 +- .../core/config/MaxTokenLifetimeOption.java | 69 +++++++ .../core/config/TokenExpirationConstants.java | 29 +++ .../config/MaxTokenLifetimeOptionTest.java | 60 ++++++ 10 files changed, 469 insertions(+), 82 deletions(-) create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateActionValidation.java create mode 100644 sonar-core/src/main/java/org/sonar/core/config/MaxTokenLifetimeOption.java create mode 100644 sonar-core/src/main/java/org/sonar/core/config/TokenExpirationConstants.java create mode 100644 sonar-core/src/test/java/org/sonar/core/config/MaxTokenLifetimeOptionTest.java 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(); + } +} -- 2.39.5