diff --git a/Makefile b/Makefile index a5606be..6944ef4 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ help: clean: @cp config/prod.docker-compose.yaml docker-compose.yaml - @docker compose down 2> /dev/null + @docker compose down @rm -f docker-compose.yaml @rm -f .env @rm -f front/MyINPulse-front/.env @@ -21,14 +21,14 @@ dev-front: clean vite @cp config/frontdev.front.env front/MyINPulse-front/.env @cp config/frontdev.main.env .env @cp config/frontdev.docker-compose.yaml docker-compose.yaml - @docker compose up -d + @docker compose up -d --build @cd ./front/MyINPulse-front/ && npm run dev prod: clean @cp config/prod.front.env front/MyINPulse-front/.env @cp config/prod.main.env .env @cp config/frontdev.docker-compose.yaml docker-compose.yaml - @docker compose up -d + @docker compose up -d --build @@ -36,6 +36,6 @@ dev-back: @cp config/backdev.front.env front/MyINPulse-front/.env @cp config/backdev.main.env .env @cp config/backdev.docker-compose.yaml docker-compose.yaml - @docker compose up -d + @docker compose up -d --build @echo "cd MyINPulse-back" @echo "./gradlew bootRun --args='--server.port=8081'" \ No newline at end of file diff --git a/MyINPulse-back/src/main/java/enseirb/myinpulse/MyinpulseApplication.java b/MyINPulse-back/src/main/java/enseirb/myinpulse/MyinpulseApplication.java index b4b5e7b..23c2f28 100644 --- a/MyINPulse-back/src/main/java/enseirb/myinpulse/MyinpulseApplication.java +++ b/MyINPulse-back/src/main/java/enseirb/myinpulse/MyinpulseApplication.java @@ -1,22 +1,22 @@ package enseirb.myinpulse; +import enseirb.myinpulse.security.KeycloakJwtRolesConverter; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; -import org.springframework.security.config.Customizer; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.builders.WebSecurity; -import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; +import org.springframework.security.oauth2.jwt.*; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.util.Arrays; +import java.util.*; +import java.util.stream.Collectors; -import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope; +import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole; @SpringBootApplication public class MyinpulseApplication { @@ -25,34 +25,5 @@ public class MyinpulseApplication { SpringApplication.run(MyinpulseApplication.class, args); } - // CORS configuration - // TODO: make sure to only accept our own domains - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Arrays.asList("*")); - configuration.setAllowedMethods(Arrays.asList("GET", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", - "x-auth-token")); // Do not remove, this fixes the CORS errors when unauthenticated - //configuration.setExposedHeaders(Arrays.asList("x-auth-token")); - UrlBasedCorsConfigurationSource source = new - UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - - return source; - } - - // TODO: add unauthenticated endpoint with server status - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/random2").access(hasScope("contacts")) - .requestMatchers("/getUserInfo").access(hasScope("messages")) - .anyRequest().authenticated() - ) - .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())); - return http.build(); - } } diff --git a/MyINPulse-back/src/main/java/enseirb/myinpulse/config/WebSecurityCustomConfiguration.java b/MyINPulse-back/src/main/java/enseirb/myinpulse/config/WebSecurityCustomConfiguration.java new file mode 100644 index 0000000..14c46b3 --- /dev/null +++ b/MyINPulse-back/src/main/java/enseirb/myinpulse/config/WebSecurityCustomConfiguration.java @@ -0,0 +1,50 @@ +package enseirb.myinpulse.config; + +import enseirb.myinpulse.security.KeycloakJwtRolesConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole; + +@Configuration +public class WebSecurityCustomConfiguration { + // CORS configuration + // TODO: make sure to only accept our own domains + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", + "x-auth-token")); // Do not remove, this fixes the CORS errors when unauthenticated + UrlBasedCorsConfigurationSource source = new + UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/random2").access(hasRole("REALM_MyINPulse-entrepreneur")) + .requestMatchers("/random").access(hasRole("REALM_MyINPulse-admin")) + .requestMatchers("/random3").permitAll() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt. + jwtAuthenticationConverter(new KeycloakJwtRolesConverter()))); + return http.build(); + + } +} \ No newline at end of file diff --git a/MyINPulse-back/src/main/java/enseirb/myinpulse/security/KeycloakJwtRolesConverter.java b/MyINPulse-back/src/main/java/enseirb/myinpulse/security/KeycloakJwtRolesConverter.java new file mode 100644 index 0000000..fafbef5 --- /dev/null +++ b/MyINPulse-back/src/main/java/enseirb/myinpulse/security/KeycloakJwtRolesConverter.java @@ -0,0 +1,98 @@ +package enseirb.myinpulse.security; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toSet; + + +public class KeycloakJwtRolesConverter implements Converter { + /** + * Prefix used for realm level roles. + */ + public static final String PREFIX_REALM_ROLE = "ROLE_REALM_"; + /** + * Prefix used in combination with the resource (client) name for resource level roles. + */ + public static final String PREFIX_RESOURCE_ROLE = "ROLE_"; + + /** + * Name of the claim containing the realm level roles + */ + private static final String CLAIM_REALM_ACCESS = "realm_access"; + /** + * Name of the claim containing the resources (clients) the user has access to. + */ + private static final String CLAIM_RESOURCE_ACCESS = "resource_access"; + /** + * Name of the claim containing roles. (Applicable to realm and resource level.) + */ + private static final String CLAIM_ROLES = "roles"; + + @Override + public AbstractAuthenticationToken convert(Jwt source) + { + return new JwtAuthenticationToken(source, Stream.concat(new JwtGrantedAuthoritiesConverter().convert(source) + .stream(), TEMPORARNAME(source).stream()) + .collect(toSet())); + } + + /** + * Extracts the realm and resource level roles from a JWT token distinguishing between them using prefixes. + */ + public Collection TEMPORARNAME(Jwt jwt) { + // Collection that will hold the extracted roles + Collection grantedAuthorities = new ArrayList<>(); + + // Realm roles + // Get the part of the access token that holds the roles assigned on realm level + Map> realmAccess = jwt.getClaim(CLAIM_REALM_ACCESS); + + // Verify that the claim exists and is not empty + if (realmAccess != null && !realmAccess.isEmpty()) { + // From the realm_access claim get the roles + Collection roles = realmAccess.get(CLAIM_ROLES); + // Check if any roles are present + if (roles != null && !roles.isEmpty()) { + // Iterate of the roles and add them to the granted authorities + Collection realmRoles = roles.stream() + // Prefix all realm roles with "ROLE_realm_" + .map(role -> new SimpleGrantedAuthority(PREFIX_REALM_ROLE + role)) + .collect(Collectors.toList()); + grantedAuthorities.addAll(realmRoles); + } + } + + // Resource (client) roles + // A user might have access to multiple resources all containing their own roles. Therefore, it is a map of + // resource each possibly containing a "roles" property. + Map>> resourceAccess = jwt.getClaim(CLAIM_RESOURCE_ACCESS); + + // Check if resources are assigned + if (resourceAccess != null && !resourceAccess.isEmpty()) { + // Iterate of all the resources + resourceAccess.forEach((resource, resourceClaims) -> { + // Iterate of the "roles" claim inside the resource claims + resourceClaims.get(CLAIM_ROLES).forEach( + // Add the role to the granted authority prefixed with ROLE_ and the name of the resource + role -> grantedAuthorities.add(new SimpleGrantedAuthority(PREFIX_RESOURCE_ROLE + resource + "_" + role)) + ); + }); + } + + return grantedAuthorities; + } + + +} diff --git a/MyINPulse-back/src/main/resources/application.properties b/MyINPulse-back/src/main/resources/application.properties index 9264594..6d6825f 100644 --- a/MyINPulse-back/src/main/resources/application.properties +++ b/MyINPulse-back/src/main/resources/application.properties @@ -1,3 +1,4 @@ spring.application.name=myinpulse spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:7080/realms/test/protocol/openid-connect/certs -spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:7080/realms/test \ No newline at end of file +spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:7080/realms/test +logging.level.org.springframework.security=DEBUG diff --git a/front/MyINPulse-front/src/helpers.ts b/front/MyINPulse-front/src/helpers.ts deleted file mode 100644 index 0bd894a..0000000 --- a/front/MyINPulse-front/src/helpers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import axios from "axios"; - -const backendUrl = "http://localhost:8080/" - -// TODO: spawn a error modal -function defaultApiErrorHandler(err: String){ - console.log(err) -} - -function defaultApiSuccessHandler(response: () => void){ - console.log(response) -} -/* -function callApi(endpoint: string, onSuccessHandler: () => void, onErrorHandler: (err: String) => void): void { - console.log("callApi: "+ endpoint) - axios.get(backendUrl + endpoint).then( - onSuccessHandler == null ? defaultApiSuccessHandler : onSuccessHandler - ).catch( - (err) => { - onErrorHandler == null ? defaultApiErrorHandler(err): onErrorHandler(err); - } - ) -} - -export {callApi} - */ \ No newline at end of file