From 46719c8d9390a493b93da1d588c34fbf3fd67070 Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Tue, 28 May 2024 14:57:52 +0200 Subject: [PATCH] Endpoint for Profile to fetch roles for user See https://www.pivotaltracker.com/story/show/187641544 --- README.md | 2 +- client/pom.xml | 2 +- pom.xml | 2 +- provisioning-mock/pom.xml | 2 +- server/pom.xml | 2 +- .../main/java/access/SwaggerOpenIdConfig.java | 8 +- .../AttributeAggregatorController.java | 4 +- .../access/api/DefaultErrorController.java | 14 ++-- .../lifecycle/UserLifeCycleController.java | 5 +- .../java/access/profile/ApplicationInfo.java | 20 +++++ .../access/profile/ProfileController.java | 79 +++++++++++++++++++ .../java/access/profile/UserRoleProfile.java | 21 +++++ .../java/access/security/SecurityConfig.java | 16 +++- .../java/access/teams/TeamsController.java | 4 +- .../main/java/access/voot/VootController.java | 4 +- server/src/main/resources/application.yml | 4 + .../access/profile/ProfileControllerTest.java | 73 +++++++++++++++++ welcome/pom.xml | 2 +- 18 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 server/src/main/java/access/profile/ApplicationInfo.java create mode 100644 server/src/main/java/access/profile/ProfileController.java create mode 100644 server/src/main/java/access/profile/UserRoleProfile.java create mode 100644 server/src/test/java/access/profile/ProfileControllerTest.java diff --git a/README.md b/README.md index ec6cee83..c7eaae20 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Graph Login with Mujina IdP and user `admin` to become super-user in the local environment - + diff --git a/client/pom.xml b/client/pom.xml index 424b9459..807f25d8 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -4,7 +4,7 @@ org.openconext access - 0.0.14 + 0.0.15-SNAPSHOT ../pom.xml access-client diff --git a/pom.xml b/pom.xml index d2329979..fc51f81d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.openconext access - 0.0.14 + 0.0.15-SNAPSHOT pom access SURFconext Invite diff --git a/provisioning-mock/pom.xml b/provisioning-mock/pom.xml index 11bb3b9e..8d2a0007 100644 --- a/provisioning-mock/pom.xml +++ b/provisioning-mock/pom.xml @@ -4,7 +4,7 @@ org.openconext access - 0.0.14 + 0.0.15-SNAPSHOT ../pom.xml provisioning-mock diff --git a/server/pom.xml b/server/pom.xml index 1363fc69..9fbd3bea 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -4,7 +4,7 @@ org.openconext access - 0.0.14 + 0.0.15-SNAPSHOT ../pom.xml access-server diff --git a/server/src/main/java/access/SwaggerOpenIdConfig.java b/server/src/main/java/access/SwaggerOpenIdConfig.java index 490f3c09..59230c0e 100644 --- a/server/src/main/java/access/SwaggerOpenIdConfig.java +++ b/server/src/main/java/access/SwaggerOpenIdConfig.java @@ -19,9 +19,7 @@ public class SwaggerOpenIdConfig { public static final String OPEN_ID_SCHEME_NAME = "openId"; public static final String API_TOKENS_SCHEME_NAME = "apiTokens"; - public static final String VOOT_SCHEME_NAME = "voot"; - public static final String LIFE_CYCLE_SCHEME_NAME = "lifeCycle"; - public static final String ATTRIBUTE_AGGREGATION_SCHEME_NAME = "attributeAggregation"; + public static final String BASIC_AUTHENTICATION_SCHEME_NAME = "basic_auth"; @Bean OpenAPI customOpenApi(@Value("${spring.security.oauth2.client.provider.oidcng.authorization-uri}") String authorizationUrl, @@ -48,9 +46,7 @@ OpenAPI customOpenApi(@Value("${spring.security.oauth2.client.provider.oidcng.au Components components = new Components() .addSecuritySchemes(OPEN_ID_SCHEME_NAME, openIdSecuritySchema) .addSecuritySchemes(API_TOKENS_SCHEME_NAME, apiTokensSecurityScheme) - .addSecuritySchemes(VOOT_SCHEME_NAME, basicAuthentication) - .addSecuritySchemes(LIFE_CYCLE_SCHEME_NAME, basicAuthentication) - .addSecuritySchemes(ATTRIBUTE_AGGREGATION_SCHEME_NAME, basicAuthentication); + .addSecuritySchemes(BASIC_AUTHENTICATION_SCHEME_NAME, basicAuthentication); OpenAPI openAPI = new OpenAPI() .info(new Info() diff --git a/server/src/main/java/access/aggregation/AttributeAggregatorController.java b/server/src/main/java/access/aggregation/AttributeAggregatorController.java index d3c0be15..4d5adfa5 100644 --- a/server/src/main/java/access/aggregation/AttributeAggregatorController.java +++ b/server/src/main/java/access/aggregation/AttributeAggregatorController.java @@ -23,11 +23,11 @@ import java.util.Map; import java.util.Optional; -import static access.SwaggerOpenIdConfig.ATTRIBUTE_AGGREGATION_SCHEME_NAME; +import static access.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; @RestController @RequestMapping(value = {"/api/aa", "/api/external/v1/aa"}, produces = MediaType.APPLICATION_JSON_VALUE) -@SecurityRequirement(name = ATTRIBUTE_AGGREGATION_SCHEME_NAME) +@SecurityRequirement(name = BASIC_AUTHENTICATION_SCHEME_NAME) public class AttributeAggregatorController { private static final Log LOG = LogFactory.getLog(AttributeAggregatorController.class); diff --git a/server/src/main/java/access/api/DefaultErrorController.java b/server/src/main/java/access/api/DefaultErrorController.java index b47c0f4f..463c37e6 100644 --- a/server/src/main/java/access/api/DefaultErrorController.java +++ b/server/src/main/java/access/api/DefaultErrorController.java @@ -14,6 +14,7 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -23,8 +24,7 @@ import java.net.URISyntaxException; import java.util.Map; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.*; @RestController @Hidden @@ -61,9 +61,13 @@ public ResponseEntity error(HttpServletRequest request) { boolean logStackTrace = !(error instanceof UserRestrictionException || error instanceof access.exception.RemoteException); LOG.error(String.format("Error occurred; %s", error), logStackTrace ? error : null); } - //https://github.com/spring-projects/spring-boot/issues/3057 - ResponseStatus annotation = AnnotationUtils.getAnnotation(error.getClass(), ResponseStatus.class); - statusCode = annotation != null ? annotation.value() : BAD_REQUEST; + if (error instanceof AccessDeniedException) { + statusCode = FORBIDDEN; + } else { + //https://github.com/spring-projects/spring-boot/issues/3057 + ResponseStatus annotation = AnnotationUtils.getAnnotation(error.getClass(), ResponseStatus.class); + statusCode = annotation != null ? annotation.value() : BAD_REQUEST; + } } result.remove("message"); result.put("status", statusCode.value()); diff --git a/server/src/main/java/access/lifecycle/UserLifeCycleController.java b/server/src/main/java/access/lifecycle/UserLifeCycleController.java index f37107d0..632fd4c4 100644 --- a/server/src/main/java/access/lifecycle/UserLifeCycleController.java +++ b/server/src/main/java/access/lifecycle/UserLifeCycleController.java @@ -18,13 +18,12 @@ import access.repository.*; import java.util.*; -import java.util.stream.Collectors; -import static access.SwaggerOpenIdConfig.LIFE_CYCLE_SCHEME_NAME; +import static access.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; @RestController @RequestMapping(value = {"/api/deprovision", "/api/external/v1/deprovision"}, produces = MediaType.APPLICATION_JSON_VALUE) -@SecurityRequirement(name = LIFE_CYCLE_SCHEME_NAME) +@SecurityRequirement(name = BASIC_AUTHENTICATION_SCHEME_NAME) public class UserLifeCycleController { private static final Logger LOG = LoggerFactory.getLogger(UserLifeCycleController.class); diff --git a/server/src/main/java/access/profile/ApplicationInfo.java b/server/src/main/java/access/profile/ApplicationInfo.java new file mode 100644 index 00000000..6139e5b3 --- /dev/null +++ b/server/src/main/java/access/profile/ApplicationInfo.java @@ -0,0 +1,20 @@ +package access.profile; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationInfo { + + private String landingPage; + private String nameEn; + private String nameNl; + private String organisationEn; + private String organisationNl; + private String logo; +} diff --git a/server/src/main/java/access/profile/ProfileController.java b/server/src/main/java/access/profile/ProfileController.java new file mode 100644 index 00000000..c533a38e --- /dev/null +++ b/server/src/main/java/access/profile/ProfileController.java @@ -0,0 +1,79 @@ +package access.profile; + +import access.manage.Manage; +import access.model.Authority; +import access.model.Role; +import access.model.User; +import access.model.UserRole; +import access.repository.UserRepository; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static access.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; + +@RestController +@RequestMapping(value = {"/api/profile", "/api/external/v1/profile"}, produces = MediaType.APPLICATION_JSON_VALUE) +@SecurityRequirement(name = BASIC_AUTHENTICATION_SCHEME_NAME) +public class ProfileController { + + private static final Log LOG = LogFactory.getLog(ProfileController.class); + + private final UserRepository userRepository; + private final Manage manage; + + public ProfileController(UserRepository userRepository, + Manage manage) { + this.userRepository = userRepository; + this.manage = manage; + } + + @GetMapping(value = "") + @PreAuthorize("hasRole('PROFILE')") + public ResponseEntity> roles(@RequestParam("collabPersonId") String collabPersonId) { + LOG.debug("Fetch profile roles for:" + collabPersonId); + + Optional optionalUser = userRepository.findBySubIgnoreCase(collabPersonId); + Set userRoles = optionalUser.map(User::getUserRoles).orElse(Collections.emptySet()); + List roles = userRoles.stream() + .filter(userRole -> userRole.getAuthority().equals(Authority.GUEST)) + .map(UserRole::getRole).toList(); + return ResponseEntity.ok( + manage.addManageMetaData(roles).stream(). + map(this::userRoleProfile) + .toList() + ); + } + + private UserRoleProfile userRoleProfile(Role role) { + return new UserRoleProfile( + role.getName(), + role.getDescription(), + applicationInfoList(role)); + } + + private List applicationInfoList(Role role) { + return role.getApplicationMaps().stream().map(applicationMap -> new ApplicationInfo( + (String) applicationMap.get("landingPage"), + (String) applicationMap.get("name:en"), + (String) applicationMap.get("name:nl"), + (String) applicationMap.get("OrganizationName:en"), + (String) applicationMap.get("OrganizationName:nl"), + (String) applicationMap.get("logo") + )).toList(); + + } + +} diff --git a/server/src/main/java/access/profile/UserRoleProfile.java b/server/src/main/java/access/profile/UserRoleProfile.java new file mode 100644 index 00000000..89bac2c0 --- /dev/null +++ b/server/src/main/java/access/profile/UserRoleProfile.java @@ -0,0 +1,21 @@ +package access.profile; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UserRoleProfile { + + private String name; + private String description; + private List applications; + + +} diff --git a/server/src/main/java/access/security/SecurityConfig.java b/server/src/main/java/access/security/SecurityConfig.java index 58588267..c1ca5c4b 100644 --- a/server/src/main/java/access/security/SecurityConfig.java +++ b/server/src/main/java/access/security/SecurityConfig.java @@ -65,6 +65,8 @@ public class SecurityConfig { private final String lifeCyclePassword; private final String teamsUser; private final String teamsPassword; + private final String profileUser; + private final String profilePassword; @Autowired public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository, @@ -80,6 +82,8 @@ public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository, @Value("${lifecycle.password}") String lifeCyclePassword, @Value("${teams.user}") String teamsUser, @Value("${teams.password}") String teamsPassword, + @Value("${profile.user}") String profileUser, + @Value("${profile.password}") String profilePassword, @Value("${attribute-aggregation.user}") String attributeAggregationUser, @Value("${attribute-aggregation.password}") String attributeAggregationPassword) { this.clientRegistrationRepository = clientRegistrationRepository; @@ -95,6 +99,8 @@ public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository, this.lifeCyclePassword = lifeCyclePassword; this.teamsUser = teamsUser; this.teamsPassword = teamsPassword; + this.profileUser = profileUser; + this.profilePassword = profilePassword; this.attributeAggregationUser = attributeAggregationUser; this.attributeAggregationPassword = attributeAggregationPassword; } @@ -205,6 +211,8 @@ SecurityFilterChain basicAuthenticationSecurityFilterChain(HttpSecurity http) th "/api/external/v1/voot/**", "/api/teams/**", "/api/external/v1/teams/**", + "/api/profile/**", + "/api/external/v1/profile/**", "/api/aa/**", "/api/external/v1/aa/**", "/api/deprovision/**", @@ -268,11 +276,17 @@ public InMemoryUserDetailsManager userDetailsService() { .password("{noop}" + lifeCyclePassword) .roles("LIFECYCLE") .build(); + UserDetails profileUserDetails = User + .withUsername(profileUser) + .password("{noop}" + profilePassword) + .roles("PROFILE") + .build(); return new InMemoryUserDetailsManager( vootUserDetails, attributeAggregationUserDetails, lifeCyleUserDetails, - teamsUserDetails); + teamsUserDetails, + profileUserDetails); } @Bean diff --git a/server/src/main/java/access/teams/TeamsController.java b/server/src/main/java/access/teams/TeamsController.java index 3c7ee4cb..c0973c91 100644 --- a/server/src/main/java/access/teams/TeamsController.java +++ b/server/src/main/java/access/teams/TeamsController.java @@ -28,11 +28,11 @@ import java.util.*; import java.util.stream.Collectors; -import static access.SwaggerOpenIdConfig.ATTRIBUTE_AGGREGATION_SCHEME_NAME; +import static access.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; @RestController @RequestMapping(value = {"/api/teams", "/api/external/v1/teams"}, produces = MediaType.APPLICATION_JSON_VALUE) -@SecurityRequirement(name = ATTRIBUTE_AGGREGATION_SCHEME_NAME) +@SecurityRequirement(name = BASIC_AUTHENTICATION_SCHEME_NAME) public class TeamsController { private static final int DEFAULT_EXPIRY_DAYS = 5 * 365; diff --git a/server/src/main/java/access/voot/VootController.java b/server/src/main/java/access/voot/VootController.java index 45a79dd8..a66c3d11 100644 --- a/server/src/main/java/access/voot/VootController.java +++ b/server/src/main/java/access/voot/VootController.java @@ -22,11 +22,11 @@ import java.util.*; import java.util.stream.Collectors; -import static access.SwaggerOpenIdConfig.VOOT_SCHEME_NAME; +import static access.SwaggerOpenIdConfig.BASIC_AUTHENTICATION_SCHEME_NAME; @RestController @RequestMapping(value = {"/api/voot", "/api/external/v1/voot"}, produces = MediaType.APPLICATION_JSON_VALUE) -@SecurityRequirement(name = VOOT_SCHEME_NAME) +@SecurityRequirement(name = BASIC_AUTHENTICATION_SCHEME_NAME) public class VootController { private static final Log LOG = LogFactory.getLog(VootController.class); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index a92ee3f8..a4948762 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -132,6 +132,10 @@ lifecycle: user: lifecycle password: secret +profile: + user: profile + password: secret + email: from: "no-reply@surf.nl" contactEmail: "access@surf.nl" diff --git a/server/src/test/java/access/profile/ProfileControllerTest.java b/server/src/test/java/access/profile/ProfileControllerTest.java new file mode 100644 index 00000000..5a8520e2 --- /dev/null +++ b/server/src/test/java/access/profile/ProfileControllerTest.java @@ -0,0 +1,73 @@ +package access.profile; + +import access.AbstractTest; +import access.manage.EntityType; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ProfileControllerTest extends AbstractTest { + + @Test + void roles() { + stubForManagerProvidersByIdIn(EntityType.SAML20_SP, List.of("1", "3", "4")); + stubForManagerProvidersByIdIn(EntityType.OIDC10_RP, List.of("5")); + + List roles = given() + .when() + .auth().basic("profile", "secret") + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .queryParam("collabPersonId", GUEST_SUB) + .get("/api/profile") + .as(new TypeRef<>() { + }); + assertEquals(3, roles.size()); + + UserRoleProfile research = roles.stream().filter(p -> p.getName().equals("Research")).findFirst().get(); + assertEquals("Research desc", research.getDescription()); + assertEquals(1, research.getApplications().size()); + + ApplicationInfo applicationInfo = research.getApplications().get(0); + assertEquals("Research EN", applicationInfo.getNameEn()); + assertEquals("Research NL", applicationInfo.getNameNl()); + assertEquals("SURF bv", applicationInfo.getOrganisationEn()); + assertNull(applicationInfo.getOrganisationNl()); + assertEquals("http://landingpage.com", applicationInfo.getLandingPage()); + assertEquals("https://static.surfconext.nl/media/idp/surfconext.png", applicationInfo.getLogo()); + + } + + @Test + void rolesNotExistentUser() { + List roles = given() + .when() + .auth().basic("profile", "secret") + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .queryParam("collabPersonId", "nope") + .get("/api/profile") + .as(new TypeRef<>() { + }); + assertEquals(0, roles.size()); + } + + @Test + void rolesForbidden() { + given() + .when() + .auth().basic("teams", "secret") + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .queryParam("collabPersonId", "nope") + .get("/api/profile") + .then() + .statusCode(403); + } +} \ No newline at end of file diff --git a/welcome/pom.xml b/welcome/pom.xml index 56cdca4d..16387fe3 100644 --- a/welcome/pom.xml +++ b/welcome/pom.xml @@ -4,7 +4,7 @@ org.openconext access - 0.0.14 + 0.0.15-SNAPSHOT ../pom.xml access-welcome