From 83c1faf0d8975b195ebf39a64a515dd06f8f9518 Mon Sep 17 00:00:00 2001 From: lucasdpt Date: Wed, 10 Sep 2025 00:05:15 +0200 Subject: [PATCH] feat: Initial Commit --- pom.xml | 48 ++++++++++++++ .../fr/lucasdupont/security/CallerId.java | 5 ++ .../security/CallerIdArgumentResolver.java | 47 ++++++++++++++ .../security/KeycloakAuthProperties.java | 28 ++++++++ .../security/KeycloakAutoConfiguration.java | 65 +++++++++++++++++++ .../KeycloakRealmAndClientRoleConverter.java | 45 +++++++++++++ .../security/RequiredRoleFilter.java | 45 +++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 8 files changed, 284 insertions(+) create mode 100644 pom.xml create mode 100644 src/main/java/fr/lucasdupont/security/CallerId.java create mode 100644 src/main/java/fr/lucasdupont/security/CallerIdArgumentResolver.java create mode 100644 src/main/java/fr/lucasdupont/security/KeycloakAuthProperties.java create mode 100644 src/main/java/fr/lucasdupont/security/KeycloakAutoConfiguration.java create mode 100644 src/main/java/fr/lucasdupont/security/KeycloakRealmAndClientRoleConverter.java create mode 100644 src/main/java/fr/lucasdupont/security/RequiredRoleFilter.java create mode 100644 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..45e2be4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + fr.lucasdupont + spring-keycloak-starter + 0.0.1 + + + 21 + 21 + UTF-8 + + 3.5.5 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + \ No newline at end of file diff --git a/src/main/java/fr/lucasdupont/security/CallerId.java b/src/main/java/fr/lucasdupont/security/CallerId.java new file mode 100644 index 0000000..904eec9 --- /dev/null +++ b/src/main/java/fr/lucasdupont/security/CallerId.java @@ -0,0 +1,5 @@ +package fr.lucasdupont.security; + +public record CallerId(String id, Type type) { + public enum Type { USER, CLIENT } +} diff --git a/src/main/java/fr/lucasdupont/security/CallerIdArgumentResolver.java b/src/main/java/fr/lucasdupont/security/CallerIdArgumentResolver.java new file mode 100644 index 0000000..5e59da0 --- /dev/null +++ b/src/main/java/fr/lucasdupont/security/CallerIdArgumentResolver.java @@ -0,0 +1,47 @@ +package fr.lucasdupont.security; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class CallerIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return CallerId.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws IllegalAccessException { + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (!(auth instanceof JwtAuthenticationToken jwtAuth)) { + throw new IllegalAccessException("Authentication is not of type JwtAuthenticationToken"); + } + + Jwt jwt = jwtAuth.getToken(); + if (jwt == null) { + throw new IllegalAccessException("No JWT token found in authentication"); + } + + String sub = jwt.getClaimAsString("sub"); + String clientId = jwt.getClaimAsString("client_id"); + String preferredUsername = jwt.getClaimAsString("preferred_username"); + + boolean looksLikeServiceAccount = preferredUsername != null && preferredUsername.startsWith("service-account-"); + if (clientId != null && looksLikeServiceAccount) { + return new CallerId(clientId, CallerId.Type.CLIENT); + } else { + return new CallerId(sub, CallerId.Type.USER); + } + } +} diff --git a/src/main/java/fr/lucasdupont/security/KeycloakAuthProperties.java b/src/main/java/fr/lucasdupont/security/KeycloakAuthProperties.java new file mode 100644 index 0000000..120183b --- /dev/null +++ b/src/main/java/fr/lucasdupont/security/KeycloakAuthProperties.java @@ -0,0 +1,28 @@ +package fr.lucasdupont.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "keycloak.auth") +public class KeycloakAuthProperties { + + private String role; + + private boolean checkClientRole = false; + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public boolean isCheckClientRole() { + return checkClientRole; + } + + public void setCheckClientRole(boolean checkClientRole) { + this.checkClientRole = checkClientRole; + } + +} diff --git a/src/main/java/fr/lucasdupont/security/KeycloakAutoConfiguration.java b/src/main/java/fr/lucasdupont/security/KeycloakAutoConfiguration.java new file mode 100644 index 0000000..578dfeb --- /dev/null +++ b/src/main/java/fr/lucasdupont/security/KeycloakAutoConfiguration.java @@ -0,0 +1,65 @@ +package fr.lucasdupont.security; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@AutoConfiguration +@ConditionalOnClass(HttpSecurity.class) +@EnableConfigurationProperties(KeycloakAuthProperties.class) +public class KeycloakAutoConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + KeycloakAuthProperties props, + RequiredRoleFilter roleFilter) throws Exception { + + JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter(); + jwtConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmAndClientRoleConverter()); + + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(registry -> registry + .requestMatchers("/actuator/health", "/actuator/info").permitAll() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)) + ); + + http.addFilterAfter(roleFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public RequiredRoleFilter requiredRoleFilter(KeycloakAuthProperties props) { + return new RequiredRoleFilter(props); + } + + @Bean + public WebMvcConfigurer callerIdArgumentResolverConfigurer(CallerIdArgumentResolver resolver) { + return new WebMvcConfigurer() { + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(resolver); + } + }; + } + + @Bean + public CallerIdArgumentResolver callerIdArgumentResolver() { + return new CallerIdArgumentResolver(); + } + +} diff --git a/src/main/java/fr/lucasdupont/security/KeycloakRealmAndClientRoleConverter.java b/src/main/java/fr/lucasdupont/security/KeycloakRealmAndClientRoleConverter.java new file mode 100644 index 0000000..971e93c --- /dev/null +++ b/src/main/java/fr/lucasdupont/security/KeycloakRealmAndClientRoleConverter.java @@ -0,0 +1,45 @@ +package fr.lucasdupont.security; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.*; +import java.util.stream.Collectors; + +public class KeycloakRealmAndClientRoleConverter implements Converter> { + + @Override + public Collection convert(Jwt jwt) { + Set roles = new HashSet<>(); + + Map realmAccess = jwt.getClaim("realm_access"); + if (realmAccess != null) { + Object rolesObj = realmAccess.get("roles"); + if (rolesObj instanceof Collection coll) { + coll.forEach(r -> roles.add(String.valueOf(r))); + } + } + + Map resourceAccess = jwt.getClaim("resource_access"); + if (resourceAccess != null) { + for (Object clientEntry : resourceAccess.values()) { + if (clientEntry instanceof Map map) { + Object rolesObj = map.get("roles"); + if (rolesObj instanceof Collection coll) { + coll.forEach(r -> roles.add(String.valueOf(r))); + } + } + } + } + + return roles.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/fr/lucasdupont/security/RequiredRoleFilter.java b/src/main/java/fr/lucasdupont/security/RequiredRoleFilter.java new file mode 100644 index 0000000..784b223 --- /dev/null +++ b/src/main/java/fr/lucasdupont/security/RequiredRoleFilter.java @@ -0,0 +1,45 @@ +package fr.lucasdupont.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class RequiredRoleFilter extends OncePerRequestFilter { + + private final KeycloakAuthProperties props; + + public RequiredRoleFilter(KeycloakAuthProperties props) { + this.props = props; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String required = props.getRole(); + if (!StringUtils.hasText(required)) { + filterChain.doFilter(request, response); + return; + } + + String authority = required.startsWith("ROLE_") ? required : ("ROLE_" + required); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getAuthorities().stream().noneMatch(a -> a.getAuthority().equals(authority))) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..b252d8a --- /dev/null +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +fr.lucasdupont.security.KeycloakAutoConfiguration \ No newline at end of file