From deebf66db508553660711069c0c1da75bb327a29 Mon Sep 17 00:00:00 2001 From: Lars Johansson <lars.johansson@ess.eu> Date: Wed, 15 May 2024 15:44:58 +0200 Subject: [PATCH] Add authentication and authorization Add (conditional) authentication and authorization with RBAC (Role Based Access Control). By default, authentication and authorization is disabled. If enabled, corresponding RBAC environment variables must also be configured. Note that commit is first step in introducing authentication and authorization. --- CONFIGURATION.md | 15 + pom.xml | 26 ++ .../openepics/names/NamingApplication.java | 16 + .../configuration/SecurityConfiguration.java | 183 +++++++++++ .../configuration/SwaggerConfiguration.java | 57 ++++ .../names/exception/ServiceException.java | 11 + .../GlobalControllerExceptionHandler.java | 33 +- .../security/AuthenticationException.java | 39 +++ .../security/EntityNotFoundException.java | 47 +++ .../exception/security/ParseException.java | 37 +++ .../exception/security/RemoteException.java | 37 +++ .../security/RemoteServiceException.java | 39 +++ .../security/UnauthorizedException.java | 37 +++ .../names/rest/api/v1/IAuthentication.java | 113 +++++++ .../openepics/names/rest/api/v1/INames.java | 39 ++- .../names/rest/api/v1/IStructures.java | 39 ++- .../names/rest/beans/security/Login.java | 47 +++ .../rest/beans/security/LoginResponse.java | 43 +++ .../controller/AuthenticationController.java | 129 ++++++++ .../controller/HealthcheckController.java | 2 +- .../rest/controller/ReportController.java | 2 +- .../names/rest/filter/JwtRequestFilter.java | 204 ++++++++++++ .../security/AuthenticationService.java | 123 ++++++++ .../service/security/JwtTokenService.java | 121 ++++++++ .../security/JwtUserDetailsService.java | 50 +++ .../names/service/security/RBACService.java | 245 +++++++++++++++ .../names/service/security/UserService.java | 126 ++++++++ .../service/security/dto/LoginTokenDto.java | 50 +++ .../service/security/dto/RoleAuthority.java | 41 +++ .../service/security/dto/UserDetails.java | 80 +++++ .../service/security/rbac/RBACToken.java | 127 ++++++++ .../service/security/rbac/RBACUserInfo.java | 47 +++ .../service/security/rbac/RBACUserRoles.java | 47 +++ .../service/security/util/ConversionUtil.java | 35 +++ .../service/security/util/EncryptUtil.java | 101 ++++++ .../security/util/HttpClientService.java | 291 ++++++++++++++++++ .../security/util/OkHttpConfiguration.java | 104 +++++++ .../security/util/SecurityTextUtil.java | 46 +++ src/main/resources/application.properties | 14 + src/test/resources/application.properties | 14 + 40 files changed, 2841 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/openepics/names/configuration/SecurityConfiguration.java create mode 100644 src/main/java/org/openepics/names/configuration/SwaggerConfiguration.java create mode 100644 src/main/java/org/openepics/names/exception/security/AuthenticationException.java create mode 100644 src/main/java/org/openepics/names/exception/security/EntityNotFoundException.java create mode 100644 src/main/java/org/openepics/names/exception/security/ParseException.java create mode 100644 src/main/java/org/openepics/names/exception/security/RemoteException.java create mode 100644 src/main/java/org/openepics/names/exception/security/RemoteServiceException.java create mode 100644 src/main/java/org/openepics/names/exception/security/UnauthorizedException.java create mode 100644 src/main/java/org/openepics/names/rest/api/v1/IAuthentication.java create mode 100644 src/main/java/org/openepics/names/rest/beans/security/Login.java create mode 100644 src/main/java/org/openepics/names/rest/beans/security/LoginResponse.java create mode 100644 src/main/java/org/openepics/names/rest/controller/AuthenticationController.java create mode 100644 src/main/java/org/openepics/names/rest/filter/JwtRequestFilter.java create mode 100644 src/main/java/org/openepics/names/service/security/AuthenticationService.java create mode 100644 src/main/java/org/openepics/names/service/security/JwtTokenService.java create mode 100644 src/main/java/org/openepics/names/service/security/JwtUserDetailsService.java create mode 100644 src/main/java/org/openepics/names/service/security/RBACService.java create mode 100644 src/main/java/org/openepics/names/service/security/UserService.java create mode 100644 src/main/java/org/openepics/names/service/security/dto/LoginTokenDto.java create mode 100644 src/main/java/org/openepics/names/service/security/dto/RoleAuthority.java create mode 100644 src/main/java/org/openepics/names/service/security/dto/UserDetails.java create mode 100644 src/main/java/org/openepics/names/service/security/rbac/RBACToken.java create mode 100644 src/main/java/org/openepics/names/service/security/rbac/RBACUserInfo.java create mode 100644 src/main/java/org/openepics/names/service/security/rbac/RBACUserRoles.java create mode 100644 src/main/java/org/openepics/names/service/security/util/ConversionUtil.java create mode 100644 src/main/java/org/openepics/names/service/security/util/EncryptUtil.java create mode 100644 src/main/java/org/openepics/names/service/security/util/HttpClientService.java create mode 100644 src/main/java/org/openepics/names/service/security/util/OkHttpConfiguration.java create mode 100644 src/main/java/org/openepics/names/service/security/util/SecurityTextUtil.java diff --git a/CONFIGURATION.md b/CONFIGURATION.md index d4f26e43..a82a147e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -6,6 +6,8 @@ Configuration may be set using SpringBoot's configuration file (`application.pro | Variable | Default | Description | |-------------------------------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| **Application** | | | +| | | | | NAMING_DATABASE_URL | jdbc:postgresql://postgres:5432/discs_names | JDBC URL for the database connection | | NAMING_DATABASE_USERNAME | discs_names | user name for the database connection | | NAMING_DATABASE_PASSWORD | discs_names | password for the database connection | @@ -22,4 +24,17 @@ Configuration may be set using SpringBoot's configuration file (`application.pro | NAMING_SMTP_PORT | 587 | port to use for SMTP, 587 for SMTP Secure | | NAMING_SMTP_USERNAME | | username for SMTP server | | NAMING_SMTP_PASSWORD | | password for SMTP server | +| | | | +| **Springdoc, Swagger** | | | +| | | | | NAMING_SWAGGER_URL | http://localhost:8080/swagger-ui.html | default url for Swagger UI | +| API_DOCS_PATH | /api-docs | custom path of the OpenAPI documentation in JSON | +| SWAGGER_UI_PATH | /swagger-ui.html | custom path of the swagger-ui documentation in HTML | +| | | | +| **Security** | | | +| | | | +| NAMING_SECURITY_ENABLED | false | enable security for application | +| AES_KEY | aes_secret_key | secret that is used to hash the tokens in the JWT token | +| JWT_SECRET | secret_password_to_hash_token | secret that is used sign the JWT token | +| JWT_EXP_MIN | 240 | time interval until the JWT token is valid (in minutes) | +| RBAC_SERVER_ADDRESS | | RBAC server address used for log in users if NAMING_SECURITY_ENABLED is enabled | diff --git a/pom.xml b/pom.xml index 7b58ad2f..c8e4c652 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,10 @@ <groupId>javax.persistence</groupId> <artifactId>javax.persistence-api</artifactId> </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-security</artifactId> + </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> @@ -59,6 +63,28 @@ <artifactId>springdoc-openapi-ui</artifactId> <version>1.8.0</version> </dependency> + <dependency> + <groupId>org.springdoc</groupId> + <artifactId>springdoc-openapi-security</artifactId> + <version>1.8.0</version> + </dependency> + <dependency> + <groupId>io.fusionauth</groupId> + <artifactId>fusionauth-jwt</artifactId> + <version>5.3.2</version> + </dependency> + <dependency> + <groupId>com.squareup.okhttp3</groupId> + <artifactId>okhttp</artifactId> + </dependency> + <dependency> + <groupId>com.google.code.gson</groupId> + <artifactId>gson</artifactId> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.dataformat</groupId> + <artifactId>jackson-dataformat-xml</artifactId> + </dependency> <dependency> <groupId>com.github.spotbugs</groupId> diff --git a/src/main/java/org/openepics/names/NamingApplication.java b/src/main/java/org/openepics/names/NamingApplication.java index 379b5211..3277fb7a 100644 --- a/src/main/java/org/openepics/names/NamingApplication.java +++ b/src/main/java/org/openepics/names/NamingApplication.java @@ -22,6 +22,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import io.swagger.v3.oas.models.ExternalDocumentation; import io.swagger.v3.oas.models.OpenAPI; @@ -67,4 +69,18 @@ public class NamingApplication { .version(appVersion)); } + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry + .addMapping("/**") + .allowedOriginPatterns("*") + .allowCredentials(true) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE"); + } + }; + } + } diff --git a/src/main/java/org/openepics/names/configuration/SecurityConfiguration.java b/src/main/java/org/openepics/names/configuration/SecurityConfiguration.java new file mode 100644 index 00000000..f85a105b --- /dev/null +++ b/src/main/java/org/openepics/names/configuration/SecurityConfiguration.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2024 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.configuration; + +import java.util.Arrays; +import java.util.List; + +import org.openepics.names.rest.filter.JwtRequestFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.StrictHttpFirewall; + +import com.google.common.collect.ImmutableList; + +/** + * Set up security configuration for Naming backend. + * + * @author Lars Johansson + */ +@Configuration +@EnableWebSecurity +public class SecurityConfiguration { + + private static final String API_V1_AUTHENTICATION = "/api/v1/authentication"; + private static final String API_V1_NAMES = "/api/v1/names"; + private static final String API_V1_NAMES_PATHS = "/api/v1/names/*"; + private static final String API_V1_STRUCTURES = "/api/v1/structures"; + private static final String API_V1_STRUCTURES_PATHS = "/api/v1/structures/*"; + private static final String API_V1_PV_NAMES = "/api/v1/pvNames"; + private static final String API_V1_CONVERT = "/api/v1/convert"; + + private static final String ACTUATOR = "/actuator"; + private static final String ACTUATOR_PATHS = "/actuator/**"; + private static final String HEALTHCHECK = "/healthcheck"; + private static final String REPORT_PATHS = "/report/**"; + + public static final String ROLE_ADMINISTRATOR = "NamingAdministrator"; + public static final String ROLE_USER = "NamingUser"; + public static final String IS_ADMINISTRATOR = "hasAuthority('" + ROLE_ADMINISTRATOR + "')"; + public static final String IS_USER = "hasAuthority('" + ROLE_USER + "')"; + public static final String IS_ADMINISTRATOR_OR_USER = IS_ADMINISTRATOR + " or " + IS_USER; + + private static final List<String> ALLOWED_ROLES_TO_LOGIN = + Arrays.asList( + SecurityConfiguration.ROLE_ADMINISTRATOR, + SecurityConfiguration.ROLE_USER); + + @Value("${naming.security.enabled:false}") + private boolean namingSecurityEnabled; + @Value("${springdoc.api-docs.path}") + private String apiDocsPath; + @Value("${springdoc.swagger-ui.path}") + private String swaggerUiPath; + + private final UserDetailsService jwtUserDetailsService; + private final JwtRequestFilter jwtRequestFilter; + + @Autowired + public SecurityConfiguration( + UserDetailsService jwtUserDetailsService, + JwtRequestFilter jwtRequestFilter) { + this.jwtUserDetailsService = jwtUserDetailsService; + this.jwtRequestFilter = jwtRequestFilter; + } + + public static List<String> getAllowedRolesToLogin() { + return ImmutableList.copyOf(ALLOWED_ROLES_TO_LOGIN); + } + + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + // configure AuthenticationManager so that it knows from where to load + // user for matching credentials + // Use BCryptPasswordEncoder + auth.userDetailsService(jwtUserDetailsService); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // disable csrf since internal + // authentication and authorization + // create, read, update, delete + // not needed for read but needed for others, user for names, admin for structures + // accept requests to + // openapi, swagger + // healthcheck + // login, logout + // patterns that are not mentioned are to be authenticated + // stateless session as session not used to store user's state + + // TODO naming-convention-tool + // about + // paths to be removed when such functionality is removed + // /verification to verify data migration + // /rest rest api for comparison + // paths + // /verification/** + // /rest/deviceNames/** + // /rest/healthcheck + // /rest/history/** + // /rest/parts/** + + http + .csrf().disable() + .cors(); + + if (namingSecurityEnabled) { + http + .authorizeRequests() + // api docs, swagger + // authentication - login, logout + // names - read + // structures - read + // other + .mvcMatchers("/v3/api-docs/**", apiDocsPath + "/**", "/api/swagger-ui/**", "/swagger-ui/**", swaggerUiPath).permitAll() + .mvcMatchers(HttpMethod.POST, API_V1_AUTHENTICATION + "/login").permitAll() + .mvcMatchers(HttpMethod.GET, API_V1_NAMES, API_V1_NAMES_PATHS).permitAll() + .mvcMatchers(HttpMethod.GET, API_V1_STRUCTURES, API_V1_STRUCTURES_PATHS).permitAll() + .mvcMatchers(HttpMethod.GET, API_V1_PV_NAMES).permitAll() + .mvcMatchers(HttpMethod.GET, API_V1_CONVERT).permitAll() + .mvcMatchers(HttpMethod.GET, REPORT_PATHS).permitAll() + .mvcMatchers(HttpMethod.GET, HEALTHCHECK).permitAll() + .mvcMatchers(HttpMethod.GET, ACTUATOR, ACTUATOR_PATHS).permitAll() + // naming-convention-tool + .mvcMatchers(HttpMethod.GET, "/verification/**").permitAll() + .mvcMatchers(HttpMethod.GET, "/rest/deviceNames/**").permitAll() + .mvcMatchers(HttpMethod.GET, "/rest/healthcheck").permitAll() + .mvcMatchers(HttpMethod.GET, "/rest/history/**").permitAll() + .mvcMatchers(HttpMethod.GET, "/rest/parts/**").permitAll() + // any other requests to be authenticated + .anyRequest().authenticated() + // add a filter to validate the tokens with every request + .and() + .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) + // make sure we use stateless session; session won't be used to store user's state. + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); + } + + return http.build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + // allow % that is URL encoded %25 may be in path + // see org.springframework.security.web.firewall.StrictHttpFirewall + return web -> web.httpFirewall(allowUrlEncodedPercenthHttpFirewall()); + } + + private HttpFirewall allowUrlEncodedPercenthHttpFirewall() { + StrictHttpFirewall firewall = new StrictHttpFirewall(); + firewall.setAllowUrlEncodedPercent(true); + return firewall; + } + +} diff --git a/src/main/java/org/openepics/names/configuration/SwaggerConfiguration.java b/src/main/java/org/openepics/names/configuration/SwaggerConfiguration.java new file mode 100644 index 00000000..f1cdf2b2 --- /dev/null +++ b/src/main/java/org/openepics/names/configuration/SwaggerConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.configuration; + +import org.springdoc.core.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.security.SecuritySchemes; + +/** + * Set up Swagger configuration for Naming backend. + * + * @author Lars Johansson + */ +@Configuration +@SecuritySchemes(value = { + @SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + in = SecuritySchemeIn.HEADER, + bearerFormat = "JWT", + scheme = "bearer") +}) +public class SwaggerConfiguration { + + @Bean + public GroupedOpenApi v1Api() { + return GroupedOpenApi.builder().group("api-v1").pathsToMatch("/api/v1/**").build(); + } + + @Bean + public GroupedOpenApi base() { + return GroupedOpenApi.builder().group("base").pathsToMatch("/report/**", "/healthcheck").build(); + } + + +} diff --git a/src/main/java/org/openepics/names/exception/ServiceException.java b/src/main/java/org/openepics/names/exception/ServiceException.java index a0bf606c..9bab827a 100644 --- a/src/main/java/org/openepics/names/exception/ServiceException.java +++ b/src/main/java/org/openepics/names/exception/ServiceException.java @@ -33,6 +33,17 @@ public class ServiceException extends RuntimeException { private final String details; private final String field; + /** + * Public constructor. + * + * @param message message + */ + public ServiceException(String message) { + super(message); + this.details = null; + this.field = null; + } + /** * Public constructor. * diff --git a/src/main/java/org/openepics/names/exception/handler/GlobalControllerExceptionHandler.java b/src/main/java/org/openepics/names/exception/handler/GlobalControllerExceptionHandler.java index a7c60a99..f18b7b52 100644 --- a/src/main/java/org/openepics/names/exception/handler/GlobalControllerExceptionHandler.java +++ b/src/main/java/org/openepics/names/exception/handler/GlobalControllerExceptionHandler.java @@ -31,6 +31,10 @@ import org.openepics.names.exception.InputNotCorrectException; import org.openepics.names.exception.InputNotEmptyException; import org.openepics.names.exception.InputNotValidException; import org.openepics.names.exception.ServiceException; +import org.openepics.names.exception.security.AuthenticationException; +import org.openepics.names.exception.security.EntityNotFoundException; +import org.openepics.names.exception.security.RemoteException; +import org.openepics.names.exception.security.UnauthorizedException; import org.openepics.names.rest.beans.response.Response; import org.openepics.names.util.TextUtil; import org.springframework.http.HttpStatus; @@ -46,10 +50,13 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExcep * Note * <ul> * <li>400 {@link HttpStatus#BAD_REQUEST}</li> + * <li>401 {@link HttpStatus#UNAUTHORIZED}</li> + * <li>403 {@link HttpStatus#FORBIDDEN}</li> * <li>404 {@link HttpStatus#NOT_FOUND}</li> * <li>409 {@link HttpStatus#CONFLICT}</li> * <li>422 {@link HttpStatus#UNPROCESSABLE_ENTITY}</li> * <li>500 {@link HttpStatus#INTERNAL_SERVER_ERROR}</li> + * <li>503 {@link HttpStatus#SERVICE_UNAVAILABLE}</li> * </ul> * * @author Lars Johansson @@ -58,7 +65,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExcep public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler - protected ResponseEntity<Response> handleConflict(RuntimeException ex, WebRequest request) { + public ResponseEntity<Response> handleConflict(RuntimeException ex, WebRequest request) { Response response = new Response("", "", ""); response.setMessage(TextUtil.OPERATION_COULD_NOT_BE_PERFORMED); @@ -71,15 +78,17 @@ public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHan // HttpStatus.BAD_REQUEST handled by Spring - if (ex instanceof InputNotAvailableException - || ex instanceof InputNotCorrectException - || ex instanceof InputNotEmptyException - || ex instanceof InputNotValidException) { - resultStatus = HttpStatus.UNPROCESSABLE_ENTITY; + if (ex instanceof AuthenticationException) { + resultStatus = HttpStatus.UNAUTHORIZED; + } + + if (ex instanceof UnauthorizedException) { + resultStatus = HttpStatus.FORBIDDEN; } if (ex instanceof DataNotAvailableException - || ex instanceof DataNotFoundException) { + || ex instanceof DataNotFoundException + || ex instanceof EntityNotFoundException) { resultStatus = HttpStatus.NOT_FOUND; } @@ -90,9 +99,17 @@ public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHan resultStatus = HttpStatus.CONFLICT; } - if (ex instanceof DataNotCorrectException) { + if (ex instanceof InputNotAvailableException + || ex instanceof InputNotCorrectException + || ex instanceof InputNotEmptyException + || ex instanceof InputNotValidException + || ex instanceof DataNotCorrectException) { resultStatus = HttpStatus.UNPROCESSABLE_ENTITY; } + + if (ex instanceof RemoteException) { + resultStatus = HttpStatus.SERVICE_UNAVAILABLE; + } } return new ResponseEntity<>(response, Response.getHeaderJson(), resultStatus); diff --git a/src/main/java/org/openepics/names/exception/security/AuthenticationException.java b/src/main/java/org/openepics/names/exception/security/AuthenticationException.java new file mode 100644 index 00000000..ec1ad8e0 --- /dev/null +++ b/src/main/java/org/openepics/names/exception/security/AuthenticationException.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2021 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.exception.security; + +import org.openepics.names.exception.ServiceException; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class AuthenticationException extends ServiceException { + + /** + * + */ + private static final long serialVersionUID = -5434486411943794281L; + + private static final String ERROR = "Authentication error"; + + public AuthenticationException(String description) { + super(ERROR, description, null, null); + } + +} diff --git a/src/main/java/org/openepics/names/exception/security/EntityNotFoundException.java b/src/main/java/org/openepics/names/exception/security/EntityNotFoundException.java new file mode 100644 index 00000000..f8780d71 --- /dev/null +++ b/src/main/java/org/openepics/names/exception/security/EntityNotFoundException.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2021 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.exception.security; + +import org.openepics.names.exception.ServiceException; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class EntityNotFoundException extends ServiceException { + + /** + * + */ + private static final long serialVersionUID = 564746316436100483L; + + private static final String ENTITY_NOT_FOUND = "Entity not found"; + + public EntityNotFoundException(String entityType, long entityId) { + super(ENTITY_NOT_FOUND, entityType + " (ID: " + entityId + ") not found", null, null); + } + + public EntityNotFoundException(String entityType, String name) { + super(ENTITY_NOT_FOUND, entityType + " (name: " + name + ") not found", null, null); + } + + public EntityNotFoundException(String message) { + super(ENTITY_NOT_FOUND, message, null, null); + } + +} diff --git a/src/main/java/org/openepics/names/exception/security/ParseException.java b/src/main/java/org/openepics/names/exception/security/ParseException.java new file mode 100644 index 00000000..0df3e3ff --- /dev/null +++ b/src/main/java/org/openepics/names/exception/security/ParseException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2021 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.exception.security; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class ParseException extends Exception { + + /** + * + */ + private static final long serialVersionUID = 7711905654503012121L; + + public ParseException() {} + + public ParseException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/openepics/names/exception/security/RemoteException.java b/src/main/java/org/openepics/names/exception/security/RemoteException.java new file mode 100644 index 00000000..c54cf6ac --- /dev/null +++ b/src/main/java/org/openepics/names/exception/security/RemoteException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.exception.security; + +import org.openepics.names.exception.ServiceException; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class RemoteException extends ServiceException { + + /** + * + */ + private static final long serialVersionUID = -1202935975436997437L; + + public RemoteException(String error, String description) { + super(error, description, null, null); + } + +} diff --git a/src/main/java/org/openepics/names/exception/security/RemoteServiceException.java b/src/main/java/org/openepics/names/exception/security/RemoteServiceException.java new file mode 100644 index 00000000..efdb01a7 --- /dev/null +++ b/src/main/java/org/openepics/names/exception/security/RemoteServiceException.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2021 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.exception.security; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +public class RemoteServiceException extends Exception { + + /** + * + */ + private static final long serialVersionUID = 3744513029294293528L; + + public RemoteServiceException(String message, Throwable cause) { + super(message, cause); + } + + public RemoteServiceException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/openepics/names/exception/security/UnauthorizedException.java b/src/main/java/org/openepics/names/exception/security/UnauthorizedException.java new file mode 100644 index 00000000..3128be98 --- /dev/null +++ b/src/main/java/org/openepics/names/exception/security/UnauthorizedException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2021 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.exception.security; + +import org.openepics.names.exception.ServiceException; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +public class UnauthorizedException extends ServiceException { + + /** + * + */ + private static final long serialVersionUID = -3051588358017181877L; + + public UnauthorizedException(String description) { + super("Operation forbidden", description, null, null); + } + +} diff --git a/src/main/java/org/openepics/names/rest/api/v1/IAuthentication.java b/src/main/java/org/openepics/names/rest/api/v1/IAuthentication.java new file mode 100644 index 00000000..612fb1fe --- /dev/null +++ b/src/main/java/org/openepics/names/rest/api/v1/IAuthentication.java @@ -0,0 +1,113 @@ +package org.openepics.names.rest.api.v1; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.openepics.names.exception.ServiceException; +import org.openepics.names.rest.beans.security.Login; +import org.openepics.names.rest.beans.security.LoginResponse; +import org.openepics.names.service.security.dto.UserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +@RequestMapping("/api/v1/authentication") +@Tag(name = "5. Authentication") +public interface IAuthentication { + + @Operation( + summary = "Login user and acquire JWT token", + description = "Login user with username, and password in order to acquire JWT token") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Ok", + content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse( + responseCode = "401", + description = "Bad username, and/or password", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "403", + description = "User doesn't have permission to log in", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "503", + description = "Login server unavailable", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "500", + description = "Service exception", + content = @Content(schema = @Schema(implementation = ServiceException.class))) + }) + @PostMapping( + value = "/login", + produces = {"application/json"}, + consumes = {"application/json"}) + ResponseEntity<LoginResponse> login(@RequestBody Login loginInfo); + + @Operation( + summary = "Renewing JWT token", + description = "Renewing valid, non-expired JWT token", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Ok", + content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "403", + description = "Permission denied", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "503", + description = "Login server unavailable", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "500", + description = "Service exception", + content = @Content(schema = @Schema(implementation = ServiceException.class))) + }) + @PostMapping( + value = "/renew", + produces = {"application/json"}) + ResponseEntity<LoginResponse> tokenRenew(@AuthenticationPrincipal UserDetails user); + + @Operation( + summary = "Logout", + description = "Logging out the user", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Ok"), + @ApiResponse( + responseCode = "500", + description = "Service exception", + content = @Content(schema = @Schema(implementation = ServiceException.class))), + @ApiResponse( + responseCode = "503", + description = "Login server unavailable or remote error", + content = @Content(schema = @Schema(implementation = ServiceException.class))) + }) + @DeleteMapping( + value = "/logout", + produces = {"application/json"}) + ResponseEntity<Object> logout(@AuthenticationPrincipal UserDetails user); + +} diff --git a/src/main/java/org/openepics/names/rest/api/v1/INames.java b/src/main/java/org/openepics/names/rest/api/v1/INames.java index 9dab9f0e..e9370c46 100644 --- a/src/main/java/org/openepics/names/rest/api/v1/INames.java +++ b/src/main/java/org/openepics/names/rest/api/v1/INames.java @@ -20,6 +20,7 @@ package org.openepics.names.rest.api.v1; import java.util.List; +import org.openepics.names.configuration.SecurityConfiguration; import org.openepics.names.rest.beans.FieldName; import org.openepics.names.rest.beans.element.NameElement; import org.openepics.names.rest.beans.element.NameElementCommandConfirm; @@ -30,6 +31,7 @@ import org.openepics.names.rest.beans.response.ResponseBoolean; import org.openepics.names.rest.beans.response.ResponseBooleanList; import org.openepics.names.rest.beans.response.ResponsePageNameElements; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -48,6 +50,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; /** @@ -142,7 +145,8 @@ public interface INames { Note - Uuid is created by Naming - """) + """, + security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponses(value = { @ApiResponse( responseCode = "201", @@ -154,6 +158,14 @@ public interface INames { responseCode = "400", description = "Bad request. Reason and information such as message, details, field are available.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), @ApiResponse( responseCode = "404", description = "Not Found. Reason and information such as message, details, field are available.", @@ -174,6 +186,7 @@ public interface INames { @PostMapping( produces = {"application/json"}, consumes = {"application/json"}) + @PreAuthorize(SecurityConfiguration.IS_ADMINISTRATOR_OR_USER) public ResponseEntity<List<NameElement>> createNames( @Parameter( in = ParameterIn.DEFAULT, @@ -664,7 +677,8 @@ public interface INames { Optional attributes: - parentDeviceStructure - index - """) + """, + security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponses(value = { @ApiResponse( responseCode = "200", @@ -676,6 +690,14 @@ public interface INames { responseCode = "400", description = "Bad request. Reason and information such as message, details, field are available.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), @ApiResponse( responseCode = "404", description = "Not Found. Reason and information such as message, details, field are available.", @@ -696,6 +718,7 @@ public interface INames { @PutMapping( produces = {"application/json"}, consumes = {"application/json"}) + @PreAuthorize(SecurityConfiguration.IS_ADMINISTRATOR_OR_USER) public List<NameElement> updateNames( @Parameter( in = ParameterIn.DEFAULT, @@ -725,7 +748,8 @@ public interface INames { Required attributes: - uuid - """) + """, + security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponses(value = { @ApiResponse( responseCode = "204", @@ -734,6 +758,14 @@ public interface INames { responseCode = "400", description = "Bad request. Reason and information such as message, details, field are available.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), @ApiResponse( responseCode = "404", description = "Not Found. Reason and information such as message, details, field are available.", @@ -750,6 +782,7 @@ public interface INames { @DeleteMapping( produces = {"application/json"}, consumes = {"application/json"}) + @PreAuthorize(SecurityConfiguration.IS_ADMINISTRATOR_OR_USER) public ResponseEntity<Response> deleteNames( @Parameter( in = ParameterIn.DEFAULT, diff --git a/src/main/java/org/openepics/names/rest/api/v1/IStructures.java b/src/main/java/org/openepics/names/rest/api/v1/IStructures.java index fed8d373..1f78f4dc 100644 --- a/src/main/java/org/openepics/names/rest/api/v1/IStructures.java +++ b/src/main/java/org/openepics/names/rest/api/v1/IStructures.java @@ -20,6 +20,7 @@ package org.openepics.names.rest.api.v1; import java.util.List; +import org.openepics.names.configuration.SecurityConfiguration; import org.openepics.names.rest.beans.FieldStructure; import org.openepics.names.rest.beans.Type; import org.openepics.names.rest.beans.element.StructureElement; @@ -31,6 +32,7 @@ import org.openepics.names.rest.beans.response.ResponseBoolean; import org.openepics.names.rest.beans.response.ResponseBooleanList; import org.openepics.names.rest.beans.response.ResponsePageStructureElements; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -49,6 +51,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; /** @@ -146,7 +149,8 @@ public interface IStructures { - Uuid is created by Naming - Parent is required for System, Subsystem, DeviceGroup, DeviceType - Mnemonic is required for System, Subsystem, Discipline, DeviceType, may be set for SystemGroup, not allowed for DeviceGroup - """) + """, + security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponses(value = { @ApiResponse( responseCode = "201", @@ -158,6 +162,14 @@ public interface IStructures { responseCode = "400", description = "Bad request. Reason and information such as message, details, field are available.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), @ApiResponse( responseCode = "404", description = "Not Found. Reason and information such as message, details, field are available.", @@ -178,6 +190,7 @@ public interface IStructures { @PostMapping( produces = {"application/json"}, consumes = {"application/json"}) + @PreAuthorize(SecurityConfiguration.IS_ADMINISTRATOR) public ResponseEntity<List<StructureElement>> createStructures( @Parameter( in = ParameterIn.DEFAULT, @@ -726,7 +739,8 @@ public interface IStructures { Note - Parent is required for System, Subsystem, DeviceGroup, DeviceType - Mnemonic is required for System, Subsystem, Discipline, DeviceType, may be set for SystemGroup, not allowed for DeviceGroup - """) + """, + security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponses(value = { @ApiResponse( responseCode = "200", @@ -738,6 +752,14 @@ public interface IStructures { responseCode = "400", description = "Bad request. Reason and information such as message, details, field are available.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), @ApiResponse( responseCode = "404", description = "Not Found. Reason and information such as message, details, field are available.", @@ -758,6 +780,7 @@ public interface IStructures { @PutMapping( produces = {"application/json"}, consumes = {"application/json"}) + @PreAuthorize(SecurityConfiguration.IS_ADMINISTRATOR) public List<StructureElement> updateStructures( @Parameter( in = ParameterIn.DEFAULT, @@ -788,7 +811,8 @@ public interface IStructures { Required attributes: - uuid - type - """) + """, + security = {@SecurityRequirement(name = "bearerAuth")}) @ApiResponses(value = { @ApiResponse( responseCode = "204", @@ -797,6 +821,14 @@ public interface IStructures { responseCode = "400", description = "Bad request. Reason and information such as message, details, field are available.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "401", + description = "Unauthorized. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Reason and information such as message, details, field are available.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = Response.class))), @ApiResponse( responseCode = "404", description = "Not Found. Reason and information such as message, details, field are available.", @@ -813,6 +845,7 @@ public interface IStructures { @DeleteMapping( produces = {"application/json"}, consumes = {"application/json"}) + @PreAuthorize(SecurityConfiguration.IS_ADMINISTRATOR) public ResponseEntity<Response> deleteStructures( @Parameter( in = ParameterIn.DEFAULT, diff --git a/src/main/java/org/openepics/names/rest/beans/security/Login.java b/src/main/java/org/openepics/names/rest/beans/security/Login.java new file mode 100644 index 00000000..6beba544 --- /dev/null +++ b/src/main/java/org/openepics/names/rest/beans/security/Login.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.rest.beans.security; + +/** + * Class is used for sending Login request trough REST API. + * + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class Login { + + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + +} diff --git a/src/main/java/org/openepics/names/rest/beans/security/LoginResponse.java b/src/main/java/org/openepics/names/rest/beans/security/LoginResponse.java new file mode 100644 index 00000000..498b0256 --- /dev/null +++ b/src/main/java/org/openepics/names/rest/beans/security/LoginResponse.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.rest.beans.security; + +/** + * Class used for sending back JWT token in response to the client if login was successful. + * + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class LoginResponse { + + private String token; + + /** + * Setting the JWT token for the Login response + * + * @param token the JWT token that will be used for the communication + */ + public LoginResponse(String token) { + this.token = token; + } + + public String getToken() { + return token; + } + +} diff --git a/src/main/java/org/openepics/names/rest/controller/AuthenticationController.java b/src/main/java/org/openepics/names/rest/controller/AuthenticationController.java new file mode 100644 index 00000000..21a31a0d --- /dev/null +++ b/src/main/java/org/openepics/names/rest/controller/AuthenticationController.java @@ -0,0 +1,129 @@ +package org.openepics.names.rest.controller; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.openepics.names.exception.ServiceException; + +import org.openepics.names.exception.security.AuthenticationException; +import org.openepics.names.exception.security.RemoteException; +import org.openepics.names.exception.security.UnauthorizedException; +import org.openepics.names.rest.api.v1.IAuthentication; +import org.openepics.names.rest.beans.security.Login; +import org.openepics.names.rest.beans.security.LoginResponse; +import org.openepics.names.service.LogService; +import org.openepics.names.service.security.AuthenticationService; +import org.openepics.names.service.security.dto.UserDetails; +import org.openepics.names.service.security.util.SecurityTextUtil; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +@RestController +public class AuthenticationController implements IAuthentication { + + private static final Logger LOGGER = Logger.getLogger(AuthenticationController.class.getName()); + + private final AuthenticationService authenticationService; + private final LogService logService; + + @Autowired + public AuthenticationController( + AuthenticationService authenticationService, + LogService logService) { + this.authenticationService = authenticationService; + this.logService = logService; + } + + @Override + public ResponseEntity<LoginResponse> login(Login loginInfo) { + try { + LoginResponse login = + authenticationService.login(loginInfo.getUsername(), loginInfo.getPassword()); + + ResponseCookie loginCookie = + ResponseCookie.from(SecurityTextUtil.COOKIE_AUTH_HEADER, login.getToken()) + .httpOnly(true) + .secure(true) + .path("/") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, loginCookie.toString()) + .body(login); + } catch (UnauthorizedException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "User doesn't have permission to log in"); + throw e; + } catch (AuthenticationException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "Error while user tried to log in"); + throw e; + } catch (RemoteException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "Remote exception while user tried to log in"); + throw e; + } catch (Exception e) { + logService.logException(LOGGER, Level.WARNING, e, "Error while trying to log in user"); + throw e; + } + } + + @Override + public ResponseEntity<LoginResponse> tokenRenew(UserDetails user) { + try { + LoginResponse renew = authenticationService.renewToken(user); + + ResponseCookie renewCookie = + ResponseCookie.from(SecurityTextUtil.COOKIE_AUTH_HEADER, renew.getToken()) + .httpOnly(true) + .secure(true) + .path("/") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, renewCookie.toString()) + .body(renew); + } catch (UnauthorizedException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "Authorization error while trying to renew token"); + throw e; + } catch (RemoteException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "Remote exception while trying to renew token"); + throw e; + } catch (Exception e) { + logService.logException(LOGGER, Level.WARNING, e, "Error while trying to renew token"); + throw new ServiceException(e.getMessage()); + } + } + + @Override + public ResponseEntity<Object> logout(UserDetails user) { + try { + authenticationService.logout(user); + + ResponseCookie deleteSpringCookie = + ResponseCookie.from(SecurityTextUtil.COOKIE_AUTH_HEADER, null) + .path("/") + .httpOnly(true) + .secure(true) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, deleteSpringCookie.toString()) + .build(); + } catch (RemoteException e) { + logService.logServiceException(LOGGER, Level.WARNING, e); + throw e; + } catch (ServiceException e) { + logService.logServiceException(LOGGER, Level.WARNING, e); + throw e; + } catch (Exception e) { + logService.logException(LOGGER, Level.WARNING, e, "Error while trying to logout user"); + throw new ServiceException(e.getMessage()); + } + } + +} diff --git a/src/main/java/org/openepics/names/rest/controller/HealthcheckController.java b/src/main/java/org/openepics/names/rest/controller/HealthcheckController.java index 524d8856..93cd1f64 100644 --- a/src/main/java/org/openepics/names/rest/controller/HealthcheckController.java +++ b/src/main/java/org/openepics/names/rest/controller/HealthcheckController.java @@ -38,7 +38,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; * * @author Lars Johansson */ -@Tag(name = "6. Healthcheck", +@Tag(name = "Healthcheck", description = "perform healthcheck for Naming application") @RestController @RequestMapping("/healthcheck") diff --git a/src/main/java/org/openepics/names/rest/controller/ReportController.java b/src/main/java/org/openepics/names/rest/controller/ReportController.java index 292f4da9..f3cb1313 100644 --- a/src/main/java/org/openepics/names/rest/controller/ReportController.java +++ b/src/main/java/org/openepics/names/rest/controller/ReportController.java @@ -61,7 +61,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; * * @author Lars Johansson */ -@Tag(name = "5. Report", +@Tag(name = "Report", description = "provide reports for Naming application") @RestController @RequestMapping("/report") diff --git a/src/main/java/org/openepics/names/rest/filter/JwtRequestFilter.java b/src/main/java/org/openepics/names/rest/filter/JwtRequestFilter.java new file mode 100644 index 00000000..9ccc4cd6 --- /dev/null +++ b/src/main/java/org/openepics/names/rest/filter/JwtRequestFilter.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2021 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.rest.filter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang3.StringUtils; +import org.openepics.names.exception.handler.GlobalControllerExceptionHandler; +import org.openepics.names.rest.beans.response.Response; +import org.openepics.names.service.LogService; +import org.openepics.names.service.security.JwtTokenService; +import org.openepics.names.service.security.JwtUserDetailsService; +import org.openepics.names.service.security.dto.UserDetails; +import org.openepics.names.service.security.util.EncryptUtil; +import org.openepics.names.service.security.util.SecurityTextUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.fusionauth.jwt.JWTExpiredException; + +/** + * Purpose of class to intercept rest calls and handle cookies and tokens in uniformed manner. + * + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = Logger.getLogger(JwtRequestFilter.class.getName()); + + private final JwtUserDetailsService jwtUserDetailsService; + private final JwtTokenService jwtTokenService; + private final EncryptUtil encryptUtil; + private final GlobalControllerExceptionHandler globalControllerExceptionHandler; + private final LogService logService; + + @Autowired + public JwtRequestFilter( + JwtUserDetailsService jwtUserDetailsService, + JwtTokenService jwtTokenService, + EncryptUtil encryptUtil, + GlobalControllerExceptionHandler globalControllerExceptionHandler, + LogService logService) { + this.jwtUserDetailsService = jwtUserDetailsService; + this.jwtTokenService = jwtTokenService; + this.encryptUtil = encryptUtil; + this.globalControllerExceptionHandler = globalControllerExceptionHandler; + this.logService = logService; + } + + private static class TokenInfo { + private String userName; + private String rbacToken; + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getRbacToken() { + return rbacToken; + } + + public void setRbacToken(String rbacToken) { + this.rbacToken = rbacToken; + } + } + + private TokenInfo extractTokenInfo(String jwtToken) { + TokenInfo result = new TokenInfo(); + try { + result.setUserName(jwtTokenService.getUsernameFromToken(jwtToken)); + result.setRbacToken(encryptUtil.decrypt(jwtTokenService.getRBACTokenFromToken(jwtToken))); + } catch (JWTExpiredException e) { + // not log as it may be considerable amount of exceptions + // otherwise message: JWT Token has expired + logService.logException(LOGGER, Level.WARNING, e, "JWT Token has expired"); + return null; + } catch (Exception e) { + logService.logException(LOGGER, Level.WARNING, e, "Unable to get info from JWT Token"); + return null; + } + + return result; + } + + @Override + protected void doFilterInternal( + HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse, + FilterChain filterChain) + throws ServletException, IOException { + + final String requestTokenHeader = httpServletRequest.getHeader("Authorization"); + + String jwtToken = null; + TokenInfo tokenInfo = null; + try { + // JWT Token is in the form "Bearer token". Remove Bearer word and get + // only the Token + if (requestTokenHeader != null) { + if (requestTokenHeader.startsWith(SecurityTextUtil.AUTHENTICATION_HEADER_PREFIX)) { + jwtToken = requestTokenHeader.substring(SecurityTextUtil.AUTHENTICATION_HEADER_PREFIX.length()); + } else { + jwtToken = requestTokenHeader; + } + + tokenInfo = extractTokenInfo(jwtToken); + } else { + Cookie cookie = WebUtils.getCookie(httpServletRequest, SecurityTextUtil.COOKIE_AUTH_HEADER); + + if (cookie != null) { + String cookieValue = cookie.getValue(); + if (StringUtils.isNotEmpty(cookieValue)) { + jwtToken = cookieValue; + tokenInfo = extractTokenInfo(cookieValue); + } + } + } + + // Once we get the token validate it. + if (tokenInfo != null + && tokenInfo.getRbacToken() != null + && SecurityContextHolder.getContext().getAuthentication() == null) { + + UserDetails userDetails = this.jwtUserDetailsService.loadUserByToken(tokenInfo.getRbacToken()); + + // if token is valid configure Spring Security to manually set + // authentication + if (jwtTokenService.validateToken(jwtToken, userDetails)) { + + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + usernamePasswordAuthenticationToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(httpServletRequest)); + // After setting the Authentication in the context, we specify + // that the current user is authenticated. So it passes the + // Spring Security Configurations successfully. + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + } + } + } catch (RuntimeException e) { + // MyObject is whatever the output of the below method + ResponseEntity<Response> objectResponseEntity = null; + try { + objectResponseEntity = globalControllerExceptionHandler.handleConflict(e, new ServletWebRequest(httpServletRequest)); + + // set the response object + httpServletResponse.setStatus(objectResponseEntity.getStatusCode().value()); + httpServletResponse.setContentType("application/json"); + + // pass down the actual obj that exception handler normally send + ObjectMapper mapper = new ObjectMapper(); + PrintWriter out = httpServletResponse.getWriter(); + out.print(mapper.writeValueAsString(objectResponseEntity.getBody())); + out.flush(); + + return; + } catch (Exception ex) { + logService.logException(LOGGER, Level.WARNING, ex, "JWT Request filter processing error"); + } + } + filterChain.doFilter(httpServletRequest, httpServletResponse); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/AuthenticationService.java b/src/main/java/org/openepics/names/service/security/AuthenticationService.java new file mode 100644 index 00000000..b3e8619e --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/AuthenticationService.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security; + +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.openepics.names.configuration.SecurityConfiguration; +import org.openepics.names.exception.ServiceException; +import org.openepics.names.exception.security.AuthenticationException; +import org.openepics.names.exception.security.UnauthorizedException; +import org.openepics.names.rest.beans.security.LoginResponse; +import org.openepics.names.service.security.dto.LoginTokenDto; +import org.openepics.names.service.security.dto.UserDetails; +import org.openepics.names.service.security.util.EncryptUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Service +public class AuthenticationService { + + private static final Logger LOGGER = Logger.getLogger(AuthenticationService.class.getName()); + + @Value("${jwt.expire.in.minutes}") + private Integer tokenExpiration; + + private final UserService userService; + private final JwtTokenService jwtTokenService; + private final EncryptUtil encryptUtil; + + @Autowired + public AuthenticationService( + UserService userService, JwtTokenService jwtTokenService, EncryptUtil encryptUtil) { + this.userService = userService; + this.jwtTokenService = jwtTokenService; + this.encryptUtil = encryptUtil; + } + + /** + * Checks if user has permission in RBAC, and if has, logs in. Successful login will result in + * creating a JWT for the REST API communication + * + * @param userName The login name for the user + * @param password The password for the user + * @return After successful login the backend will generate a JWT that can be user for the REST + * API communication + * @throws AuthenticationException If user has bad username/password, or doesn't have permission + * to log in + */ + public LoginResponse login(String userName, String password) throws AuthenticationException { + LoginTokenDto tokenDto = userService.loginUser(userName, password); + // checking user roles in RBAC + checkUserRights(tokenDto.getRoles(), userName); + + UserDetails userDetails = new UserDetails(); + userDetails.setUserName(userName); + userDetails.setToken(encryptUtil.encrypt(tokenDto.getToken())); + userDetails.setRoles(tokenDto.getRoles()); + + long tokenExpiresIn = Math.min(tokenExpiration, tokenDto.getExpirationDuration()); + + return new LoginResponse(jwtTokenService.generateToken(userDetails, tokenExpiresIn)); + } + + /** + * Checks if user has roles in RBAC at the beginning of the login process + * + * @param userRoles The roles of the user according to RBAC + * @param userName The name of the user + * @throws UnauthorizedException If user doesn't have permissions in RBAC + */ + private void checkUserRights(List<String> userRoles, String userName) throws UnauthorizedException { + List<String> rolesUserHas = + userRoles.stream() + .filter(SecurityConfiguration.getAllowedRolesToLogin()::contains) + .toList(); + + if (rolesUserHas.isEmpty()) { + LOGGER.log(Level.WARNING, "User ({}) doesn't have permission to log in", userName); + throw new UnauthorizedException("You don't have permission to log in"); + } + } + + public LoginResponse renewToken(UserDetails user) { + LoginTokenDto token = userService.renewToken(user.getToken()); + user.setToken(encryptUtil.encrypt(user.getToken())); + + long tokenExpiresIn = Math.min(tokenExpiration, token.getExpirationDuration()); + + return new LoginResponse(jwtTokenService.generateToken(user, tokenExpiresIn)); + } + + public void logout(UserDetails user) throws ServiceException { + userService.logoutUser(user.getToken()); + } + + public List<String> getUserRoles(UserDetails user) { + UserDetails userInfo = userService.getUserInfoFromToken(user.getToken()); + return userInfo.getRoles(); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/JwtTokenService.java b/src/main/java/org/openepics/names/service/security/JwtTokenService.java new file mode 100644 index 00000000..6a047013 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/JwtTokenService.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security; + +import io.fusionauth.jwt.Signer; +import io.fusionauth.jwt.Verifier; +import io.fusionauth.jwt.domain.JWT; +import io.fusionauth.jwt.hmac.HMACSigner; +import io.fusionauth.jwt.hmac.HMACVerifier; + +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Date; + +import org.openepics.names.service.security.dto.UserDetails; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Service +public class JwtTokenService { + + private static final String TOKEN = "token"; + private static final String ROLES = "roles"; + + @Value("${jwt.secret}") + private String secret; + + /** + * Retrieves userName from JWT token. + * + * @param token the JWT token. + * @return the userName from the JWT token. + */ + public String getUsernameFromToken(String token) { + return decodeJWT(token).subject; + } + + public String getRBACTokenFromToken(String token) { + return decodeJWT(token).getAllClaims().get(TOKEN).toString(); + } + + // check if the token has expired + private Boolean isTokenExpired(String token) { + return decodeJWT(token).isExpired(); + } + + /** + * Retrieving a field value from the JWT token. + * + * @param token The JWT token. + * @return The decoded value of the JWT token. + */ + public JWT decodeJWT(String token) { + Verifier verifier = HMACVerifier.newVerifier(secret); + return JWT.getDecoder().decode(token, verifier); + } + + /** + * Generates JWT token from userInformation, and expiration interval. + * + * @param userDetails Information about the user. + * @param tokenExpiration JWT token expiration interval. + * @return The generated JWT token. + */ + // while creating the token - + // 1. Define claims of the token, like Issuer, Expiration, Subject, and the ID + // 2. Sign the JWT using the HS512 algorithm and secret key. + // 3. According to JWS Compact + // Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1) + // compaction of the JWT to a URL-safe string + public String generateToken(UserDetails userDetails, long tokenExpiration) { + Date jwtTokenValidity = new Date(System.currentTimeMillis() + tokenExpiration * 60 * 1000); + Signer signer = HMACSigner.newSHA512Signer(secret); + JWT jwt = + new JWT() + .setIssuer("Naming-tool") + .setIssuedAt(ZonedDateTime.now(ZoneOffset.UTC)) + .setSubject(userDetails.getUserName()) + .addClaim(TOKEN, userDetails.getToken()) + .addClaim(ROLES, userDetails.getRoles()) + .setExpiration(ZonedDateTime.ofInstant(jwtTokenValidity.toInstant(), ZoneId.systemDefault())); + + return JWT.getEncoder().encode(jwt, signer); + } + + // validate token + + /** + * Checks if the JWT token is valid. + * + * @param token The JWT token that has to be checked. + * @param userDetails The users details. + * @return <code>true</code> if the JWT token is valid <code>false</code> if the JWT token is not + * valid + */ + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = getUsernameFromToken(token); + return (username.equals(userDetails.getUserName()) && !isTokenExpired(token)); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/JwtUserDetailsService.java b/src/main/java/org/openepics/names/service/security/JwtUserDetailsService.java new file mode 100644 index 00000000..822e7d88 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/JwtUserDetailsService.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security; + +import org.openepics.names.service.security.dto.UserDetails; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Service +public class JwtUserDetailsService implements UserDetailsService { + + private final UserService userService; + + @Autowired + public JwtUserDetailsService(UserService userService) { + this.userService = userService; + } + + public UserDetails loadUserByToken(String token) { + return userService.getUserInfoFromToken(token); + } + + @Override + public org.springframework.security.core.userdetails.UserDetails loadUserByUsername(String s) + throws UsernameNotFoundException { + return null; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/RBACService.java b/src/main/java/org/openepics/names/service/security/RBACService.java new file mode 100644 index 00000000..a44877b3 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/RBACService.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security; + +import java.util.Base64; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.openepics.names.exception.security.AuthenticationException; +import org.openepics.names.exception.security.EntityNotFoundException; +import org.openepics.names.exception.security.ParseException; +import org.openepics.names.exception.security.RemoteException; +import org.openepics.names.exception.security.RemoteServiceException; +import org.openepics.names.exception.security.UnauthorizedException; +import org.openepics.names.service.LogService; +import org.openepics.names.service.security.rbac.RBACToken; +import org.openepics.names.service.security.rbac.RBACUserInfo; +import org.openepics.names.service.security.rbac.RBACUserRoles; +import org.openepics.names.service.security.util.HttpClientService; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import okhttp3.Headers; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Service +public class RBACService { + + private static final Logger LOGGER = Logger.getLogger(RBACService.class.getName()); + + private static final String RBAC_SERVER_BASEURL = "rbac.server.address"; + private static final String AUTH_PATH = "auth"; + private static final String TOKEN_PATH = AUTH_PATH + "/token"; + + private static final String AUTHENTICATION_EXCEPTION = "Authentication Exception"; + + private final Environment env; + private final HttpClientService httpClientService; + private final LogService logService; + + @Autowired + public RBACService(Environment env, HttpClientService httpClientService, LogService logService) { + this.env = env; + this.httpClientService = httpClientService; + this.logService = logService; + } + + /** + * Tries to log in user with specific parameters into RBAC, and if successful then give back + * userInformation, and a token. + * + * @param userName the loginName for the user + * @param password the password for the user + * @return userInformation, and token for the successfully logged in user + * @throws AuthenticationException if authentication fails + */ + public RBACToken loginUser(String userName, String password) throws RemoteException, AuthenticationException { + String secretHeader = Base64.getEncoder().encodeToString((userName + ":" + password).getBytes()); + Headers headers = new Headers.Builder().add("Authorization", "BASIC " + secretHeader).build(); + + // trying to log in + try { + HttpClientService.ServiceResponse<RBACToken> rbacTokenServiceResponse = + httpClientService.executePostRequest( + headers, + env.getProperty(RBAC_SERVER_BASEURL) + TOKEN_PATH, + null, + HttpClientService.XML_MEDIA_TYPE, + RBACToken.class); + + // successful login + if (HttpClientService.isSuccessHttpStatusCode(rbacTokenServiceResponse.getStatusCode())) { + return rbacTokenServiceResponse.getEntity(); + } + } catch (ParseException | RemoteServiceException e) { + logService.logException(LOGGER, Level.WARNING, e, "Login error"); + throw new RemoteException(AUTHENTICATION_EXCEPTION, "Error while trying to log in to RBAC"); + } + + throw new AuthenticationException("Bad username/password"); + } + + /** + * Gathers information about the logged in user. + * + * @param token the token for the logged in user. + * @return information about the logged in user. + * @throws UnauthorizedException if the token is not valid + */ + public RBACToken userInfoFromToken(String token) throws UnauthorizedException, RemoteException { + Headers headers = new Headers.Builder().build(); + + try { + HttpClientService.ServiceResponse<RBACToken> rbacTokenServiceResponse = + httpClientService.executeGetRequest( + headers, + env.getProperty(RBAC_SERVER_BASEURL) + TOKEN_PATH + "/" + token, + HttpClientService.XML_MEDIA_TYPE, + RBACToken.class); + + // token check was successful + if (HttpClientService.isSuccessHttpStatusCode(rbacTokenServiceResponse.getStatusCode())) { + return rbacTokenServiceResponse.getEntity(); + } + } catch (RemoteServiceException | ParseException e) { + throw new RemoteException(AUTHENTICATION_EXCEPTION, "Error while trying to getting token info from RBAC"); + } + + throw new UnauthorizedException("Token error"); + } + + /** + * Can be used to renew a valid client token in RBAC. + * + * @param token the token for the logged in user. + * @return the renewed token, and user information. + * @throws UnauthorizedException if token was not valid for renewing. + */ + public RBACToken renewToken(String token) throws UnauthorizedException, RemoteException { + Headers headers = new Headers.Builder().build(); + + try { + HttpClientService.ServiceResponse<RBACToken> rbacTokenServiceResponse = + httpClientService.executePostRequest( + headers, + env.getProperty(RBAC_SERVER_BASEURL) + TOKEN_PATH + "/" + token + "/renew", + null, + HttpClientService.XML_MEDIA_TYPE, + RBACToken.class); + + // token renewal was successful + if (HttpClientService.isSuccessHttpStatusCode(rbacTokenServiceResponse.getStatusCode())) { + return rbacTokenServiceResponse.getEntity(); + } + } catch (RemoteServiceException | ParseException e) { + throw new RemoteException(AUTHENTICATION_EXCEPTION, "Error while trying to renew token in RBAC"); + } + + throw new UnauthorizedException("Token renewal error"); + } + + /** + * Can be used to log out a user from RBAC (deletes the token). + * + * @param token the token for the logged in user. + * @throws RemoteException if token was not valid, or already deleted + */ + public void deleteToken(String token) throws RemoteException { + Headers headers = new Headers.Builder().build(); + + try { + Integer respCode = + httpClientService.executeDeleteRequest( + headers, env.getProperty(RBAC_SERVER_BASEURL) + TOKEN_PATH + "/" + token); + + // deleting token was successful + if (HttpClientService.isSuccessHttpStatusCode(respCode)) { + return; + } + LOGGER.log(Level.WARNING, "Failed to delete RBAC token, response code: {}", respCode); + throw new RemoteException( + "RBAC error", "Failed to delete RBAC token, response code: " + respCode); + } catch (RemoteServiceException | ParseException e) { + logService.logException(LOGGER, Level.WARNING, e, "Failed to delete RBAC token"); + throw new RemoteException("RBAC error", "Failed to delete RBAC token: " + e.getMessage()); + } + } + + public RBACUserInfo fetchUsersByRole(String role) throws AuthenticationException, RemoteException { + Headers headers = new Headers.Builder().build(); + + try { + HttpClientService.ServiceResponse<RBACUserInfo> rbacTokenServiceResponse = + httpClientService.executeGetRequest( + headers, + env.getProperty(RBAC_SERVER_BASEURL) + AUTH_PATH + "/" + role + "/users", + HttpClientService.XML_MEDIA_TYPE, + RBACUserInfo.class); + + // fetching users was successful + if (HttpClientService.isSuccessHttpStatusCode(rbacTokenServiceResponse.getStatusCode())) { + return rbacTokenServiceResponse.getEntity(); + } + + if (HttpStatus.NOT_FOUND.value() == rbacTokenServiceResponse.getStatusCode()) { + throw new EntityNotFoundException("RBAC role", role); + } + } catch (RemoteServiceException | ParseException e) { + throw new RemoteException(AUTHENTICATION_EXCEPTION, "Error while trying get users from RBAC"); + } + + throw new UnauthorizedException("Getting user info error"); + } + + /** + * Fetching roles from RBAC based on username. + * + * @param username The login name to fetch the user roles. + * @throws RemoteException When error occurs when trying to fetch roles from RBAC + * @return List of roles that is associated to the user in RBAC + */ + public RBACUserRoles fetchRolesByUsername(String username) throws AuthenticationException, RemoteException { + Headers headers = new Headers.Builder().build(); + + try { + HttpClientService.ServiceResponse<RBACUserRoles> rbacTokenServiceResponse = + httpClientService.executeGetRequest( + headers, + env.getProperty(RBAC_SERVER_BASEURL) + AUTH_PATH + "/" + username + "/role", + HttpClientService.XML_MEDIA_TYPE, + RBACUserRoles.class); + + // fetching users was successful + if (HttpClientService.isSuccessHttpStatusCode(rbacTokenServiceResponse.getStatusCode())) { + return rbacTokenServiceResponse.getEntity(); + } + } catch (RemoteServiceException | ParseException e) { + throw new RemoteException(AUTHENTICATION_EXCEPTION, "Error while trying get roles by username from RBAC"); + } + + throw new RemoteException(AUTHENTICATION_EXCEPTION, "Error while trying to fetch user-roles"); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/UserService.java b/src/main/java/org/openepics/names/service/security/UserService.java new file mode 100644 index 00000000..4a679f98 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/UserService.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.openepics.names.exception.ServiceException; +import org.openepics.names.exception.security.AuthenticationException; +import org.openepics.names.exception.security.UnauthorizedException; +import org.openepics.names.service.LogService; +import org.openepics.names.service.security.dto.LoginTokenDto; +import org.openepics.names.service.security.dto.UserDetails; +import org.openepics.names.service.security.rbac.RBACToken; +import org.openepics.names.service.security.util.ConversionUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Service +public class UserService { + + private static final Logger LOGGER = Logger.getLogger(UserService.class.getName()); + + private final RBACService rbacService; + private final LogService logService; + + @Autowired + public UserService(RBACService rbacService, LogService logService) { + this.rbacService = rbacService; + this.logService = logService; + } + + /** + * Logs in user with username/password in RBAC, and if successful, generates JWT token + * + * @param userName The user's login name + * @param password The user's password + * @return LoginTokenDto with roles that can be user for generating JWT token + * @throws AuthenticationException When user tries to log in with bad username/password + * @throws ServiceException If _other_ error occurs during the authentication process + */ + public LoginTokenDto loginUser(String userName, String password) + throws ServiceException, AuthenticationException { + + try { + RBACToken rbacToken = rbacService.loginUser(userName, password); + + LocalDateTime createTime = LocalDateTime.ofInstant(rbacToken.getCreationTime().toInstant(), ZoneId.systemDefault()); + LocalDateTime expirationTime = LocalDateTime.ofInstant(rbacToken.getExpirationTime().toInstant(), ZoneId.systemDefault()); + + Duration duration = Duration.between(createTime, expirationTime); + long expireInMinutes = Math.abs(duration.toMinutes()); + + return new LoginTokenDto(rbacToken.getId(), expireInMinutes, rbacToken.getRoles()); + } catch (AuthenticationException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "Error while trying to log in user to RBAC"); + throw e; + } catch (ServiceException e) { + logService.logServiceException(LOGGER, Level.WARNING, e, "Error while trying to log in user to RBAC"); + throw e; + } + } + + /** + * Extracting user-info from RBAC using the token they have + * + * @param token The user's login token + * @return The user-information stored in RBAC + */ + public UserDetails getUserInfoFromToken(String token) { + return ConversionUtil.convertToUserDetails(rbacService.userInfoFromToken(token)); + } + + /** + * Trying to renew user's token in RBAC. + * + * @param token The user's login token. + * @return Information about the renewed token (including the new expiration date) + * @throws ServiceException When unexpected error occurs during the renewal process + * @throws UnauthorizedException When RBAC gives error during the token renewal process + */ + public LoginTokenDto renewToken(String token) throws ServiceException, UnauthorizedException { + RBACToken rbacToken = rbacService.renewToken(token); + + LocalDateTime createTime = LocalDateTime.ofInstant(rbacToken.getCreationTime().toInstant(), ZoneId.systemDefault()); + LocalDateTime expirationTime = LocalDateTime.ofInstant(rbacToken.getExpirationTime().toInstant(), ZoneId.systemDefault()); + + Duration duration = Duration.between(createTime, expirationTime); + long expireInMinutes = Math.abs(duration.toMinutes()); + + return new LoginTokenDto(rbacToken.getId(), expireInMinutes, rbacToken.getRoles()); + } + + /** + * Trying to log out user from RBAC. This will result in deleting token from the system. + * + * @param token The user's token that has to be deleted from RBAC + * @throws ServiceException When error occurs during the token logout process + */ + public void logoutUser(String token) throws ServiceException { + rbacService.deleteToken(token); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/dto/LoginTokenDto.java b/src/main/java/org/openepics/names/service/security/dto/LoginTokenDto.java new file mode 100644 index 00000000..5a64ece4 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/dto/LoginTokenDto.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.dto; + +import java.util.List; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class LoginTokenDto { + + private String token; + private long expirationDuration; + private List<String> roles; + + public LoginTokenDto(String token, long expirationDuration, List<String> roles) { + this.token = token; + this.expirationDuration = expirationDuration; + this.roles = roles; + } + + public String getToken() { + return token; + } + + public long getExpirationDuration() { + return expirationDuration; + } + + public List<String> getRoles() { + return roles; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/dto/RoleAuthority.java b/src/main/java/org/openepics/names/service/security/dto/RoleAuthority.java new file mode 100644 index 00000000..cbc4a2f0 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/dto/RoleAuthority.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.dto; + +import org.springframework.security.core.GrantedAuthority; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +public class RoleAuthority implements GrantedAuthority { + + private static final long serialVersionUID = -47836193761080412L; + + private String role; + + public RoleAuthority(String role) { + this.role = role; + } + + @Override + public String getAuthority() { + return role; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/dto/UserDetails.java b/src/main/java/org/openepics/names/service/security/dto/UserDetails.java new file mode 100644 index 00000000..6c26717e --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/dto/UserDetails.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.dto; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + + +import org.springframework.security.core.GrantedAuthority; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class UserDetails { + + private String userName; + private String fullName; + private String token; + private Collection<RoleAuthority> authorities; + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public Collection<? extends GrantedAuthority> getAuthorities() { + return authorities; + } + + public List<String> getRoles() { + return authorities != null + ? authorities.stream().map(RoleAuthority::getAuthority).collect(Collectors.toList()) + : Collections.emptyList(); + } + + public void setRoles(List<String> roles) { + this.authorities = + roles != null + ? roles.stream().map(RoleAuthority::new).collect(Collectors.toList()) + : Collections.emptyList(); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/rbac/RBACToken.java b/src/main/java/org/openepics/names/service/security/rbac/RBACToken.java new file mode 100644 index 00000000..2c246a3e --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/rbac/RBACToken.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.rbac; + +import java.util.Date; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class RBACToken { + + private String id; + + @JsonProperty("username") + private String userName; + private String firstName; + private String lastName; + private Date creationTime; + private Date expirationTime; + private String ip; + private List<String> roles; + private String signature; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public Date getCreationTime() { + return RBACToken.cloneDate(creationTime); + } + + public void setCreationTime(Date creationTime) { + this.creationTime = RBACToken.cloneDate(creationTime); + } + + public Date getExpirationTime() { + return RBACToken.cloneDate(expirationTime); + } + + public void setExpirationTime(Date expirationTime) { + this.expirationTime = RBACToken.cloneDate(expirationTime); + } + + public String getIp() { + return ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public List<String> getRoles() { + return roles; + } + + public void setRoles(List<String> roles) { + this.roles = roles; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } + + /** + * Clones a Date to make object immutable. + * + * @param dateToClone the date that has to be cloned. + * @return <code>null</code>, if date was null <code>cloned date</code>, if date was not null + */ + public static Date cloneDate(Date dateToClone) { + return dateToClone == null ? null : new Date(dateToClone.getTime()); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/rbac/RBACUserInfo.java b/src/main/java/org/openepics/names/service/security/rbac/RBACUserInfo.java new file mode 100644 index 00000000..55364255 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/rbac/RBACUserInfo.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.rbac; + +import java.util.List; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +public class RBACUserInfo { + + private String role; + private List<String> users; + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public List<String> getUsers() { + return users; + } + + public void setUsers(List<String> users) { + this.users = users; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/rbac/RBACUserRoles.java b/src/main/java/org/openepics/names/service/security/rbac/RBACUserRoles.java new file mode 100644 index 00000000..f4fe1b8c --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/rbac/RBACUserRoles.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.rbac; + +import java.util.List; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +public class RBACUserRoles { + + private String username; + private List<String> roles; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public List<String> getRoles() { + return roles; + } + + public void setRoles(List<String> roles) { + this.roles = roles; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/util/ConversionUtil.java b/src/main/java/org/openepics/names/service/security/util/ConversionUtil.java new file mode 100644 index 00000000..6a6a5328 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/util/ConversionUtil.java @@ -0,0 +1,35 @@ +package org.openepics.names.service.security.util; + +import org.openepics.names.service.security.dto.UserDetails; +import org.openepics.names.service.security.rbac.RBACToken; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +public class ConversionUtil { + + private ConversionUtil() { + } + + /** + * Converts RBAC token to user details DTO + * + * @param rbacInfo RBAC token descriptor + * @return User details DTO + */ + public static UserDetails convertToUserDetails(RBACToken rbacInfo) { + UserDetails result = null; + + if (rbacInfo != null) { + result = new UserDetails(); + + result.setUserName(rbacInfo.getUserName()); + result.setFullName(rbacInfo.getFirstName() + " " + rbacInfo.getLastName()); + result.setToken(rbacInfo.getId()); + result.setRoles(rbacInfo.getRoles()); + } + + return result; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/util/EncryptUtil.java b/src/main/java/org/openepics/names/service/security/util/EncryptUtil.java new file mode 100644 index 00000000..9520e915 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/util/EncryptUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Encryption utility. + * + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Component +public class EncryptUtil { + + private static final Logger LOGGER = Logger.getLogger(EncryptUtil.class.getName()); + + @Value("${aes.key}") + private String secret; + + private static SecretKeySpec secretKey; + private static final String ALGORITHM = "AES"; + + private void prepareSecretKey(String myKey) { + MessageDigest sha = null; + try { + byte[] key = myKey.getBytes(StandardCharsets.UTF_8); + sha = MessageDigest.getInstance("SHA-1"); + key = sha.digest(key); + key = Arrays.copyOf(key, 16); + secretKey = new SecretKeySpec(key, ALGORITHM); + } catch (NoSuchAlgorithmException e) { + LOGGER.log(Level.WARNING, SecurityTextUtil.ALGORITHM_NOT_FOUND, e); + } + } + + /** + * String encryption wrapper. + * + * @param strToEncrypt string to encrypt + * @return encrypted string + */ + public String encrypt(String strToEncrypt) { + try { + prepareSecretKey(secret); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + return Base64.getEncoder() + .encodeToString(cipher.doFinal(strToEncrypt.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + LOGGER.log(Level.WARNING, SecurityTextUtil.ERROR_WHILE_ENCRYPTING, e); + } + return null; + } + + /** + * String decryption wrapper. + * + * @param strToDecrypt string to decrypt + * @return decrypted string + */ + public String decrypt(String strToDecrypt) { + try { + prepareSecretKey(secret); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + return new String(cipher.doFinal(Base64.getDecoder().decode(strToDecrypt))); + } catch (Exception e) { + LOGGER.log(Level.WARNING, SecurityTextUtil.ERROR_WHILE_DECRYPTING, e); + } + return null; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/util/HttpClientService.java b/src/main/java/org/openepics/names/service/security/util/HttpClientService.java new file mode 100644 index 00000000..b93f95cf --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/util/HttpClientService.java @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.util; + +import java.io.IOException; +import java.lang.reflect.Type; + +import org.openepics.names.exception.security.ParseException; +import org.openepics.names.exception.security.RemoteServiceException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * @author <a href="mailto:zoltan.runyo@ess.eu">Zoltan Runyo</a> + */ +@Service +public class HttpClientService { + + public static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json; charset=utf-8"); + public static final MediaType XML_MEDIA_TYPE = MediaType.get("application/xml; charset=utf-8"); + + private static final String UNABLE_TO_CALL_SERVICE = "Unable to call service"; + + private final OkHttpClient okHttpClient; + + @Autowired + public HttpClientService(OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + public static class ServiceResponse<T> { + private final T entity; + private final int statusCode; + private final String errorMessage; + private final Headers headers; + + public ServiceResponse(T entity, int statusCode, Headers headers) { + this.entity = entity; + this.statusCode = statusCode; + this.errorMessage = null; + this.headers = headers; + } + + public ServiceResponse(int statusCode, String errorMessage, Headers headers) { + this.entity = null; + this.statusCode = statusCode; + this.errorMessage = errorMessage; + this.headers = headers; + } + + public T getEntity() { + return entity; + } + + public int getStatusCode() { + return statusCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public Headers getHeaders() { + return headers; + } + } + + public <T> ServiceResponse<T> executeGetRequest( + Headers headers, String url, Class<T> responseClass) throws RemoteServiceException { + return executeGetRequest(headers, url, responseClass, null); + } + + public <T> ServiceResponse<T> executeGetRequest( + Headers headers, String url, Class<T> responseClass, String formatDate) + throws RemoteServiceException { + Request request; + request = new Request.Builder().headers(headers).url(url).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + Gson gson = new Gson(); + if (formatDate != null) { + gson = new GsonBuilder().setDateFormat(formatDate).create(); + } + return new ServiceResponse<>( + gson.fromJson(response.body().string(), responseClass), + response.code(), + response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException(UNABLE_TO_CALL_SERVICE, e); + } + } + + public <T> ServiceResponse<T> executeGetRequest( + Headers headers, String url, Type type, String formatDate) throws RemoteServiceException { + Request request; + request = new Request.Builder().headers(headers).url(url).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>( + new GsonBuilder() + .setDateFormat(formatDate) + .create() + .fromJson(response.body().string(), type), + response.code(), + response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException(UNABLE_TO_CALL_SERVICE, e); + } + } + + public <T> ServiceResponse<T> executeGetRequest(Headers headers, String url, Type type) + throws RemoteServiceException { + Request request; + request = new Request.Builder().headers(headers).url(url).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>( + new GsonBuilder().create().fromJson(response.body().string(), type), + response.code(), + response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException(UNABLE_TO_CALL_SERVICE, e); + } + } + + public <T> ServiceResponse<T> executeGetRequest( + Headers headers, String url, MediaType mediaType, Class<T> responseClass) + throws RemoteServiceException, ParseException { + + if (JSON_MEDIA_TYPE.equals(mediaType)) { + return executeGetRequest(headers, url, responseClass); + } + + Request request; + + request = new Request.Builder().headers(headers).url(url).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>( + new XmlMapper().readValue(response.body().string(), responseClass), + response.code(), + response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException("Unable to call service with GET method", e); + } + } + + public ServiceResponse<String> executePlainGetRequest(Headers headers, String url) + throws RemoteServiceException { + Request request; + request = new Request.Builder().headers(headers).url(url).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>(response.body().string(), response.code(), response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException(UNABLE_TO_CALL_SERVICE, e); + } + } + + public <T> ServiceResponse<T> executePostRequest( + Headers headers, String url, Object requestBody, Class<T> responseClass) + throws RemoteServiceException { + RequestBody body = RequestBody.create(new Gson().toJson(requestBody), JSON_MEDIA_TYPE); + Request request; + request = new Request.Builder().headers(headers).url(url).post(body).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>( + new Gson().fromJson(response.body().string(), responseClass), + response.code(), + response.headers()); + } + return new ServiceResponse<>( + response.code(), + response.body() != null ? response.body().string() : null, + response.headers()); + } catch (IOException e) { + throw new RemoteServiceException(UNABLE_TO_CALL_SERVICE, e); + } + } + + public ServiceResponse<String> executePlainPostRequest( + Headers headers, String url, Object requestBody) throws RemoteServiceException { + RequestBody body = RequestBody.create(new Gson().toJson(requestBody), JSON_MEDIA_TYPE); + Request request; + request = new Request.Builder().headers(headers).url(url).post(body).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>(response.body().string(), response.code(), response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException(UNABLE_TO_CALL_SERVICE, e); + } + } + + public <T> ServiceResponse<T> executePostRequest( + Headers headers, String url, Object requestBody, MediaType mediaType, Class<T> responseClass) + throws RemoteServiceException, ParseException { + if (JSON_MEDIA_TYPE.equals(mediaType)) { + return executePostRequest(headers, url, requestBody, responseClass); + } + + Request request; + + try { + RequestBody body = + RequestBody.create(new XmlMapper().writeValueAsString(requestBody), XML_MEDIA_TYPE); + + request = new Request.Builder().headers(headers).url(url).post(body).build(); + } catch (JsonProcessingException e) { + throw new ParseException("Unable to parse object"); + } + + try (Response response = okHttpClient.newCall(request).execute()) { + if (isSuccessHttpStatusCode(response.code())) { + return new ServiceResponse<>( + new XmlMapper().readValue(response.body().string(), responseClass), + response.code(), + response.headers()); + } + return new ServiceResponse<>(null, response.code(), response.headers()); + } catch (IOException e) { + throw new RemoteServiceException("Unable to call service with POST method", e); + } + } + + public Integer executeDeleteRequest(Headers headers, String url) + throws RemoteServiceException, ParseException { + + Request request; + + request = new Request.Builder().headers(headers).url(url).delete().build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + return response.code(); + } catch (IOException e) { + throw new RemoteServiceException("Unable to call service with POST method", e); + } + } + + public static boolean isSuccessHttpStatusCode(int code) { + return code >= 200 && code < 300; + } + +} diff --git a/src/main/java/org/openepics/names/service/security/util/OkHttpConfiguration.java b/src/main/java/org/openepics/names/service/security/util/OkHttpConfiguration.java new file mode 100644 index 00000000..d9fbdf5b --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/util/OkHttpConfiguration.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.util; + +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import okhttp3.ConnectionPool; +import okhttp3.OkHttpClient; + +/** + * @author <a href="mailto:imre.toth@ess.eu">Imre Toth</a> + */ +@Configuration +public class OkHttpConfiguration { + + public static final String ALLOW_UNTRUSTED_CERTS = "allow.untrusted.certs"; + + private final Environment env; + + @Autowired + public OkHttpConfiguration(Environment env) { + this.env = env; + } + + @Bean + public OkHttpClient okHttpClient() { + + OkHttpClient.Builder clientBuilder = + new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .connectionPool(pool()); + + boolean allowUntrusted = BooleanUtils.toBoolean(env.getProperty(ALLOW_UNTRUSTED_CERTS)); + + if (allowUntrusted) { + final X509TrustManager trustManager = + new X509TrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + @Override + public void checkServerTrusted(final X509Certificate[] chain, final String authType) {} + + @Override + public void checkClientTrusted(final X509Certificate[] chain, final String authType) {} + }; + + SSLContext sslContext = null; + try { + sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, new TrustManager[] {trustManager}, new java.security.SecureRandom()); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + return null; + } + + clientBuilder.sslSocketFactory(sslContext.getSocketFactory(), trustManager); + + HostnameVerifier hostnameVerifier = (hostname, session) -> true; + clientBuilder.hostnameVerifier(hostnameVerifier); + } + + return clientBuilder.build(); + } + + @Bean + public ConnectionPool pool() { + return new ConnectionPool(40, 10, TimeUnit.SECONDS); + } + +} diff --git a/src/main/java/org/openepics/names/service/security/util/SecurityTextUtil.java b/src/main/java/org/openepics/names/service/security/util/SecurityTextUtil.java new file mode 100644 index 00000000..8a389269 --- /dev/null +++ b/src/main/java/org/openepics/names/service/security/util/SecurityTextUtil.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.openepics.names.service.security.util; + +/** + * Utility class to assist in handling of text, in particular for security related matters. + * + * @author Lars Johansson + */ +public class SecurityTextUtil { + + // authentication & authorization + + public static final String AUTHENTICATION_HEADER_PREFIX = "Bearer "; + public static final String COOKIE_AUTH_HEADER = "naming-auth"; + + // encryption + + public static final String ALGORITHM_NOT_FOUND = "Algorithm not found"; + public static final String ERROR_WHILE_DECRYPTING = "Error while decrypting"; + public static final String ERROR_WHILE_ENCRYPTING = "Error while encrypting"; + + /** + * This class is not to be instantiated. + */ + private SecurityTextUtil() { + throw new IllegalStateException("Utility class"); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7655d823..17f3c225 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,6 +17,18 @@ logging.level.org.springframework.web=INFO logging.level.org.hibernate.SQL=INFO spring.http.log-request-details=true +# security +# enable +# disabled by default - allow run of application without association to particular way of authentication & authorization +# encryption +# token +# rbac +naming.security.enabled=${NAMING_SECURITY_ENABLED:false} +aes.key=${AES_KEY:aes_secret_key} +jwt.secret=${JWT_SECRET:secret_password_to_hash_token} +jwt.expire.in.minutes=${JWT_EXP_MIN:240} +rbac.server.address=${RBAC_SERVER_ADDRESS} + # mail, notification # administrator mail - comma-separated list of email addresses # notification mail - comma-separated list of email addresses @@ -75,6 +87,8 @@ openapi.info.title=Naming REST API # api # swagger # url used for notification +springdoc.api-docs.path=${API_DOCS_PATH:/api-docs} +springdoc.swagger-ui.path=${SWAGGER_UI_PATH:/swagger-ui.html} springdoc.swagger-ui.tagsSorter=alpha springdoc.swagger-ui.operationsSorter=method naming.swagger.url=${NAMING_SWAGGER_URL:http://localhost:8080/swagger-ui.html} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index fc49f4c1..4f1fdda9 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -17,6 +17,18 @@ logging.level.org.springframework.web=INFO logging.level.org.hibernate.SQL=INFO spring.http.log-request-details=true +# security +# enable +# disabled by default - allow run of application without association to particular way of authentication & authorization +# encryption +# token +# rbac +naming.security.enabled=${NAMING_SECURITY_ENABLED:false} +aes.key=${AES_KEY:aes_secret_key} +jwt.secret=${JWT_SECRET:secret_password_to_hash_token} +jwt.expire.in.minutes=${JWT_EXP_MIN:240} +rbac.server.address=${RBAC_SERVER_ADDRESS} + # mail, notification # administrator mail - comma-separated list of email addresses # notification mail - comma-separated list of email addresses @@ -76,6 +88,8 @@ openapi.info.title=Naming REST API # api # swagger # url used for notification +springdoc.api-docs.path=${API_DOCS_PATH:/api-docs} +springdoc.swagger-ui.path=${SWAGGER_UI_PATH:/swagger-ui.html} springdoc.swagger-ui.tagsSorter=alpha springdoc.swagger-ui.operationsSorter=method naming.swagger.url=${NAMING_SWAGGER_URL:http://localhost:8080/swagger-ui.html} -- GitLab