Skip to content

Commit aef5435

Browse files
committed
Add configurable role case sensitivity
1 parent 76e5242 commit aef5435

8 files changed

Lines changed: 284 additions & 0 deletions

File tree

security/src/main/java/io/micronaut/security/config/SecurityConfiguration.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ default boolean isRejectNotFound() {
5454
return true;
5555
}
5656

57+
/**
58+
* @since 5.1.0
59+
* @return Whether role comparison is case-sensitive.
60+
*/
61+
default boolean isRolesCaseSensitive() {
62+
return true;
63+
}
64+
5765
/**
5866
* @since 3.7.2
5967
* @return Whether the intercept URL patterns should be prepended with context path if defined.

security/src/main/java/io/micronaut/security/config/SecurityConfigurationProperties.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ public class SecurityConfigurationProperties implements SecurityConfiguration {
5858
@SuppressWarnings("WeakerAccess")
5959
public static final boolean DEFAULT_REJECT_NOT_FOUND = true;
6060

61+
/**
62+
* The default roles case-sensitive value.
63+
*/
64+
@SuppressWarnings("WeakerAccess")
65+
public static final boolean DEFAULT_ROLES_CASE_SENSITIVE = true;
66+
6167
private boolean enabled = DEFAULT_ENABLED;
6268

6369
private boolean interceptUrlMapPrependPatternWithContextPath = DEFAULT_INTERCEPT_URL_MAP_PREPEND_PATTERN_WITH_CONTEXT_PATH;
@@ -66,6 +72,7 @@ public class SecurityConfigurationProperties implements SecurityConfiguration {
6672
private List<String> ipPatterns = Collections.singletonList(ANYWHERE);
6773
private AuthenticationStrategy authenticationProviderStrategy = DEFAULT_AUTHENTICATION_STRATEGY;
6874
private boolean rejectNotFound = DEFAULT_REJECT_NOT_FOUND;
75+
private boolean rolesCaseSensitive = DEFAULT_ROLES_CASE_SENSITIVE;
6976

7077
@Nullable
7178
private AuthenticationMode authentication = null;
@@ -166,4 +173,19 @@ public boolean isRejectNotFound() {
166173
public void setRejectNotFound(boolean rejectNotFound) {
167174
this.rejectNotFound = rejectNotFound;
168175
}
176+
177+
@Override
178+
public boolean isRolesCaseSensitive() {
179+
return rolesCaseSensitive;
180+
}
181+
182+
/**
183+
* Whether role comparison is case-sensitive. When set to {@code false}, roles differing only by case are treated as equivalent.
184+
* Default value ({@value #DEFAULT_ROLES_CASE_SENSITIVE}).
185+
*
186+
* @param rolesCaseSensitive True if roles should be compared case-sensitively
187+
*/
188+
public void setRolesCaseSensitive(boolean rolesCaseSensitive) {
189+
this.rolesCaseSensitive = rolesCaseSensitive;
190+
}
169191
}

security/src/main/java/io/micronaut/security/token/DefaultRolesFinder.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@
1919
import static java.util.Collections.emptyList;
2020
import static java.util.Collections.singletonList;
2121

22+
import io.micronaut.security.config.SecurityConfiguration;
2223
import org.jspecify.annotations.NonNull;
2324
import org.jspecify.annotations.Nullable;
2425
import io.micronaut.security.token.config.TokenConfiguration;
26+
import jakarta.inject.Inject;
2527
import jakarta.inject.Singleton;
2628
import java.util.ArrayList;
29+
import java.util.Collection;
30+
import java.util.HashSet;
2731
import java.util.List;
32+
import java.util.Locale;
2833
import java.util.Map;
34+
import java.util.Set;
2935

3036
/**
3137
* Default implementation of {@link RolesFinder}.
@@ -37,13 +43,29 @@
3743
public class DefaultRolesFinder implements RolesFinder {
3844

3945
private final TokenConfiguration tokenConfiguration;
46+
private final boolean rolesCaseSensitive;
4047

4148
/**
4249
* Constructs a Roles Parser.
4350
* @param tokenConfiguration General Token Configuration
4451
*/
4552
public DefaultRolesFinder(TokenConfiguration tokenConfiguration) {
53+
this(tokenConfiguration, true);
54+
}
55+
56+
/**
57+
* Constructs a Roles Parser.
58+
* @param tokenConfiguration General Token Configuration
59+
* @param securityConfiguration General Security Configuration
60+
*/
61+
@Inject
62+
public DefaultRolesFinder(TokenConfiguration tokenConfiguration, SecurityConfiguration securityConfiguration) {
63+
this(tokenConfiguration, securityConfiguration.isRolesCaseSensitive());
64+
}
65+
66+
private DefaultRolesFinder(TokenConfiguration tokenConfiguration, boolean rolesCaseSensitive) {
4667
this.tokenConfiguration = tokenConfiguration;
68+
this.rolesCaseSensitive = rolesCaseSensitive;
4769
}
4870

4971
/**
@@ -80,4 +102,33 @@ private List<String> rolesAtObject(@Nullable Object rolesObject) {
80102
public List<String> resolveRoles(@Nullable Map<String, Object> attributes) {
81103
return rolesAtObject(attributes != null ? attributes.get(tokenConfiguration.getRolesName()) : null);
82104
}
105+
106+
@Override
107+
public boolean hasAnyRequiredRoles(@NonNull List<String> requiredRoles, @NonNull Collection<String> grantedRoles) {
108+
if (rolesCaseSensitive) {
109+
return RolesFinder.super.hasAnyRequiredRoles(requiredRoles, grantedRoles);
110+
}
111+
if (requiredRoles.isEmpty() || grantedRoles.isEmpty()) {
112+
return false;
113+
}
114+
Set<String> normalizedGrantedRoles = new HashSet<>(grantedRoles.size());
115+
boolean grantedRolesContainsNull = false;
116+
for (String grantedRole : grantedRoles) {
117+
if (grantedRole == null) {
118+
grantedRolesContainsNull = true;
119+
} else {
120+
normalizedGrantedRoles.add(grantedRole.toLowerCase(Locale.ROOT));
121+
}
122+
}
123+
for (String requiredRole : requiredRoles) {
124+
if (requiredRole == null) {
125+
if (grantedRolesContainsNull) {
126+
return true;
127+
}
128+
} else if (normalizedGrantedRoles.contains(requiredRole.toLowerCase(Locale.ROOT))) {
129+
return true;
130+
}
131+
}
132+
return false;
133+
}
83134
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package io.micronaut.security.authorization
2+
3+
import io.micronaut.context.annotation.Requires
4+
import io.micronaut.http.HttpRequest
5+
import io.micronaut.http.MediaType
6+
import io.micronaut.http.annotation.Controller
7+
import io.micronaut.http.annotation.Get
8+
import io.micronaut.http.annotation.Produces
9+
import io.micronaut.security.MockAuthenticationProvider
10+
import io.micronaut.security.SuccessAuthenticationScenario
11+
import io.micronaut.security.annotation.Secured
12+
import io.micronaut.security.authentication.Authentication
13+
import io.micronaut.security.testutils.EmbeddedServerSpecification
14+
import io.micronaut.security.token.RolesFinder
15+
import io.micronaut.security.utils.DefaultSecurityService
16+
import io.micronaut.security.utils.SecurityService
17+
import jakarta.inject.Singleton
18+
19+
import java.security.Principal
20+
21+
class SecuredRolesCaseInsensitiveConfigurationSpec extends EmbeddedServerSpecification {
22+
23+
@Override
24+
String getSpecName() {
25+
'SecuredRolesCaseInsensitiveConfigurationSpec'
26+
}
27+
28+
@Override
29+
Map<String, Object> getConfiguration() {
30+
super.configuration + ['micronaut.security.roles-case-sensitive': false]
31+
}
32+
33+
void "@Secured annotation value can be case insensitive"() {
34+
when:
35+
client.exchange(HttpRequest.GET("/uppercase").basicAuth('user', 'password'), String)
36+
37+
then:
38+
noExceptionThrown()
39+
40+
when:
41+
client.exchange(HttpRequest.GET("/lowercase").basicAuth('user', 'password'), String)
42+
43+
then:
44+
noExceptionThrown()
45+
}
46+
47+
void "SecurityService::hasRole can be case insensitive"() {
48+
when:
49+
Authentication authentication = Authentication.build("sherlock", ["ROLE_DETECTIVE"])
50+
SecurityService securityService = new CustomSecurityService(applicationContext.getBean(RolesFinder), authentication)
51+
52+
then:
53+
securityService.hasRole('ROLE_DETECTIVE')
54+
55+
and:
56+
securityService.hasRole('role_detective')
57+
}
58+
59+
@Requires(property = 'spec.name', value = 'SecuredRolesCaseInsensitiveConfigurationSpec')
60+
@Controller
61+
static class RolesCaseInsensitiveController {
62+
63+
@Produces(MediaType.TEXT_PLAIN)
64+
@Secured(["role_user"])
65+
@Get("/lowercase")
66+
String lowercase(Principal principal) {
67+
principal.name
68+
}
69+
70+
@Produces(MediaType.TEXT_PLAIN)
71+
@Secured(["ROLE_USER"])
72+
@Get("/uppercase")
73+
String uppercase(Principal principal) {
74+
principal.name
75+
}
76+
}
77+
78+
@Singleton
79+
@Requires(property = 'spec.name', value = 'SecuredRolesCaseInsensitiveConfigurationSpec')
80+
static class AuthenticationProviderUserPassword extends MockAuthenticationProvider {
81+
AuthenticationProviderUserPassword() {
82+
super([new SuccessAuthenticationScenario('user', ['ROLE_USER'])])
83+
}
84+
}
85+
86+
@Requires(property = 'spec.name', value = 'SecuredRolesCaseInsensitiveConfigurationSpec')
87+
static class CustomSecurityService extends DefaultSecurityService {
88+
89+
Authentication authentication
90+
91+
CustomSecurityService(RolesFinder rolesFinder, Authentication authentication) {
92+
super(rolesFinder)
93+
this.authentication = authentication
94+
}
95+
96+
@Override
97+
Optional<Authentication> getAuthentication() {
98+
Optional.of(authentication)
99+
}
100+
}
101+
}

security/src/test/groovy/io/micronaut/security/config/SecurityConfigurationPropertiesSpec.groovy

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ class SecurityConfigurationPropertiesSpec extends Specification {
8989
config.interceptUrlMap[3].pattern == '/health'
9090
config.interceptUrlMap[3].access == ['isAnonymous()']
9191
config.interceptUrlMap[3].httpMethod == HttpMethod.POST
92+
config.rolesCaseSensitive
93+
94+
cleanup:
95+
ctx.stop()
96+
}
97+
98+
void "test roles case sensitive configuration"() {
99+
given:
100+
def ctx = ApplicationContext.run([
101+
'micronaut.security.roles-case-sensitive': false
102+
])
103+
104+
when:
105+
SecurityConfigurationProperties config = ctx.getBean(SecurityConfigurationProperties)
106+
107+
then:
108+
!config.rolesCaseSensitive
92109

93110
cleanup:
94111
ctx.stop()

security/src/test/groovy/io/micronaut/security/rules/ConfigurationInterceptUrlMapRuleSpec.groovy

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,22 @@ class ConfigurationInterceptUrlMapRuleSpec extends Specification {
7777
['isAuthenticated()'] | ['ROLE_ADMIN', 'ROLE_USER', 'isAuthenticated()'] | SecurityRuleResult.ALLOWED
7878
['ROLE_ADMIN', 'ROLE_USER'] | ['ROLE_USER'] | SecurityRuleResult.ALLOWED
7979
['ROLE_ADMIN'] | ['ROLE_USER'] | SecurityRuleResult.REJECTED
80+
['role_admin'] | ['ROLE_ADMIN'] | SecurityRuleResult.REJECTED
8081
['isAnonymous()'] | [SecurityRule.IS_ANONYMOUS] | SecurityRuleResult.ALLOWED
8182
['isAuthenticated()'] | [SecurityRule.IS_AUTHENTICATED] | SecurityRuleResult.ALLOWED
8283
description = expected == SecurityRuleResult.ALLOWED ? 'Allowed' : 'Rejected'
8384
}
85+
86+
def 'configured case-insensitive role comparison applies to intercept url map rules'() {
87+
given:
88+
def securityConfiguration = Stub(SecurityConfiguration) {
89+
getInterceptUrlMap() >> []
90+
isRolesCaseSensitive() >> false
91+
}
92+
RolesFinder caseInsensitiveRolesFinder = new DefaultRolesFinder(tokenConfiguration, securityConfiguration)
93+
ConfigurationInterceptUrlMapRule provider = new ConfigurationInterceptUrlMapRule(caseInsensitiveRolesFinder, securityConfiguration, new DefaultInterceptUrlPatternModifier(securityConfiguration, () -> null))
94+
95+
expect:
96+
SecurityRuleResult.ALLOWED == Mono.from(provider.compareRoles(['role_admin'], ['ROLE_ADMIN'])).block()
97+
}
8498
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package io.micronaut.security.token
2+
3+
import io.micronaut.security.config.SecurityConfiguration
4+
import io.micronaut.security.authentication.AuthenticationMode
5+
import io.micronaut.security.token.config.TokenConfiguration
6+
import spock.lang.Specification
7+
import spock.lang.Unroll
8+
9+
class DefaultRolesFinderSpec extends Specification {
10+
11+
TokenConfiguration tokenConfiguration = new TokenConfiguration() {}
12+
13+
void "default constructor keeps role comparison case sensitive"() {
14+
given:
15+
DefaultRolesFinder rolesFinder = new DefaultRolesFinder(tokenConfiguration)
16+
17+
expect:
18+
rolesFinder.hasAnyRequiredRoles(['ROLE_ADMIN'], ['ROLE_ADMIN'])
19+
!rolesFinder.hasAnyRequiredRoles(['role_admin'], ['ROLE_ADMIN'])
20+
}
21+
22+
@Unroll
23+
void "configured case insensitive role comparison for #requiredRoles and #grantedRoles is #expected"() {
24+
given:
25+
SecurityConfiguration securityConfiguration = Stub(SecurityConfiguration) {
26+
isRolesCaseSensitive() >> false
27+
}
28+
DefaultRolesFinder rolesFinder = new DefaultRolesFinder(tokenConfiguration, securityConfiguration)
29+
30+
expect:
31+
rolesFinder.hasAnyRequiredRoles(requiredRoles, grantedRoles) == expected
32+
33+
where:
34+
requiredRoles | grantedRoles || expected
35+
['role_admin'] | ['ROLE_ADMIN'] || true
36+
['role_admin'] | ['ROLE_USER'] || false
37+
[] | ['ROLE_ADMIN'] || false
38+
['role_admin'] | [] || false
39+
[null] | [null] || true
40+
[null, 'role_admin'] | ['ROLE_ADMIN'] || true
41+
}
42+
43+
void "security configuration roles case-sensitive default is true"() {
44+
given:
45+
SecurityConfiguration securityConfiguration = new SecurityConfiguration() {
46+
@Override
47+
List<String> getIpPatterns() {
48+
[]
49+
}
50+
51+
@Override
52+
List getInterceptUrlMap() {
53+
[]
54+
}
55+
56+
@Override
57+
boolean isInterceptUrlMapPrependPatternWithContextPath() {
58+
true
59+
}
60+
61+
@Override
62+
AuthenticationMode getAuthentication() {
63+
null
64+
}
65+
}
66+
67+
expect:
68+
securityConfiguration.rolesCaseSensitive
69+
}
70+
}

src/main/docs/guide/securityConfiguration.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ The following global configuration options are available:
22

33
include::{includedir}configurationProperties/io.micronaut.security.config.SecurityConfigurationProperties.adoc[]
44

5+
By default, role comparisons are case-sensitive. Set `micronaut.security.roles-case-sensitive=false` to make the default `RolesFinder` compare roles case-insensitively for authorization rules and `SecurityService.hasRole`. Enable this only when your role model intentionally treats roles differing only by case as equivalent.

0 commit comments

Comments
 (0)