Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ default boolean isRejectNotFound() {
return true;
}

/**
* @since 5.1.0
* @return Whether role comparison is case-sensitive.
*/
default boolean isRolesCaseSensitive() {
return true;
}
Comment thread
sdelamo marked this conversation as resolved.

/**
* @since 3.7.2
* @return Whether the intercept URL patterns should be prepended with context path if defined.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public class SecurityConfigurationProperties implements SecurityConfiguration {
@SuppressWarnings("WeakerAccess")
public static final boolean DEFAULT_REJECT_NOT_FOUND = true;

/**
* The default roles case-sensitive value.
*/
@SuppressWarnings("WeakerAccess")
public static final boolean DEFAULT_ROLES_CASE_SENSITIVE = true;

private boolean enabled = DEFAULT_ENABLED;

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

@Nullable
private AuthenticationMode authentication = null;
Expand Down Expand Up @@ -166,4 +173,19 @@ public boolean isRejectNotFound() {
public void setRejectNotFound(boolean rejectNotFound) {
this.rejectNotFound = rejectNotFound;
}

@Override
public boolean isRolesCaseSensitive() {
return rolesCaseSensitive;
}

/**
* Whether role comparison is case-sensitive. When set to {@code false}, roles differing only by case are treated as equivalent.
* Default value ({@value #DEFAULT_ROLES_CASE_SENSITIVE}).
*
* @param rolesCaseSensitive True if roles should be compared case-sensitively
*/
public void setRolesCaseSensitive(boolean rolesCaseSensitive) {
this.rolesCaseSensitive = rolesCaseSensitive;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;

import io.micronaut.security.config.SecurityConfiguration;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import io.micronaut.security.token.config.TokenConfiguration;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

/**
* Default implementation of {@link RolesFinder}.
Expand All @@ -37,13 +43,29 @@
public class DefaultRolesFinder implements RolesFinder {

private final TokenConfiguration tokenConfiguration;
private final boolean rolesCaseSensitive;

/**
* Constructs a Roles Parser.
* @param tokenConfiguration General Token Configuration
*/
public DefaultRolesFinder(TokenConfiguration tokenConfiguration) {
this(tokenConfiguration, true);
}

/**
* Constructs a Roles Parser.
* @param tokenConfiguration General Token Configuration
* @param securityConfiguration General Security Configuration
*/
@Inject
public DefaultRolesFinder(TokenConfiguration tokenConfiguration, SecurityConfiguration securityConfiguration) {
this(tokenConfiguration, securityConfiguration.isRolesCaseSensitive());
}

private DefaultRolesFinder(TokenConfiguration tokenConfiguration, boolean rolesCaseSensitive) {
this.tokenConfiguration = tokenConfiguration;
this.rolesCaseSensitive = rolesCaseSensitive;
}

/**
Expand Down Expand Up @@ -80,4 +102,33 @@
public List<String> resolveRoles(@Nullable Map<String, Object> attributes) {
return rolesAtObject(attributes != null ? attributes.get(tokenConfiguration.getRolesName()) : null);
}

@Override
public boolean hasAnyRequiredRoles(@NonNull List<String> requiredRoles, @NonNull Collection<String> grantedRoles) {
if (rolesCaseSensitive) {
return RolesFinder.super.hasAnyRequiredRoles(requiredRoles, grantedRoles);
}
if (requiredRoles.isEmpty() || grantedRoles.isEmpty()) {
return false;
}
Set<String> normalizedGrantedRoles = new HashSet<>(grantedRoles.size());

Check warning on line 114 in security/src/main/java/io/micronaut/security/token/DefaultRolesFinder.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this call to the constructor with the better suited static method HashSet.newHashSet(int numMappings)

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-security&issues=AZ5PSu9ndP8IGaXrGIoR&open=AZ5PSu9ndP8IGaXrGIoR&pullRequest=2192
boolean grantedRolesContainsNull = false;
for (String grantedRole : grantedRoles) {
if (grantedRole == null) {
grantedRolesContainsNull = true;
} else {
normalizedGrantedRoles.add(grantedRole.toLowerCase(Locale.ROOT));
}
}
for (String requiredRole : requiredRoles) {
if (requiredRole == null) {
if (grantedRolesContainsNull) {
return true;
}
} else if (normalizedGrantedRoles.contains(requiredRole.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
Comment thread
sdelamo marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package io.micronaut.security.authorization

import io.micronaut.context.annotation.Requires
import io.micronaut.http.HttpRequest
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Produces
import io.micronaut.security.MockAuthenticationProvider
import io.micronaut.security.SuccessAuthenticationScenario
import io.micronaut.security.annotation.Secured
import io.micronaut.security.authentication.Authentication
import io.micronaut.security.testutils.EmbeddedServerSpecification
import io.micronaut.security.token.RolesFinder
import io.micronaut.security.utils.DefaultSecurityService
import io.micronaut.security.utils.SecurityService
import jakarta.inject.Singleton

import java.security.Principal

class SecuredRolesCaseInsensitiveConfigurationSpec extends EmbeddedServerSpecification {

@Override
String getSpecName() {
'SecuredRolesCaseInsensitiveConfigurationSpec'
}

@Override
Map<String, Object> getConfiguration() {
super.configuration + ['micronaut.security.roles-case-sensitive': false]
}

void "@Secured annotation value can be case insensitive"() {
when:
client.exchange(HttpRequest.GET("/uppercase").basicAuth('user', 'password'), String)

then:
noExceptionThrown()

when:
client.exchange(HttpRequest.GET("/lowercase").basicAuth('user', 'password'), String)

then:
noExceptionThrown()
}

void "SecurityService::hasRole can be case insensitive"() {
when:
Authentication authentication = Authentication.build("sherlock", ["ROLE_DETECTIVE"])
SecurityService securityService = new CustomSecurityService(applicationContext.getBean(RolesFinder), authentication)

then:
securityService.hasRole('ROLE_DETECTIVE')

and:
securityService.hasRole('role_detective')
}

@Requires(property = 'spec.name', value = 'SecuredRolesCaseInsensitiveConfigurationSpec')
@Controller
static class RolesCaseInsensitiveController {

@Produces(MediaType.TEXT_PLAIN)
@Secured(["role_user"])
@Get("/lowercase")
String lowercase(Principal principal) {
principal.name
}

@Produces(MediaType.TEXT_PLAIN)
@Secured(["ROLE_USER"])
@Get("/uppercase")
String uppercase(Principal principal) {
principal.name
}
}

@Singleton
@Requires(property = 'spec.name', value = 'SecuredRolesCaseInsensitiveConfigurationSpec')
static class AuthenticationProviderUserPassword extends MockAuthenticationProvider {
AuthenticationProviderUserPassword() {
super([new SuccessAuthenticationScenario('user', ['ROLE_USER'])])
}
}

@Requires(property = 'spec.name', value = 'SecuredRolesCaseInsensitiveConfigurationSpec')
static class CustomSecurityService extends DefaultSecurityService {

Authentication authentication

CustomSecurityService(RolesFinder rolesFinder, Authentication authentication) {
super(rolesFinder)
this.authentication = authentication
}

@Override
Optional<Authentication> getAuthentication() {
Optional.of(authentication)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ class SecurityConfigurationPropertiesSpec extends Specification {
config.interceptUrlMap[3].pattern == '/health'
config.interceptUrlMap[3].access == ['isAnonymous()']
config.interceptUrlMap[3].httpMethod == HttpMethod.POST
config.rolesCaseSensitive

cleanup:
ctx.stop()
}

void "test roles case sensitive configuration"() {
given:
def ctx = ApplicationContext.run([
'micronaut.security.roles-case-sensitive': false
])

when:
SecurityConfigurationProperties config = ctx.getBean(SecurityConfigurationProperties)

then:
!config.rolesCaseSensitive

cleanup:
ctx.stop()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,22 @@ class ConfigurationInterceptUrlMapRuleSpec extends Specification {
['isAuthenticated()'] | ['ROLE_ADMIN', 'ROLE_USER', 'isAuthenticated()'] | SecurityRuleResult.ALLOWED
['ROLE_ADMIN', 'ROLE_USER'] | ['ROLE_USER'] | SecurityRuleResult.ALLOWED
['ROLE_ADMIN'] | ['ROLE_USER'] | SecurityRuleResult.REJECTED
['role_admin'] | ['ROLE_ADMIN'] | SecurityRuleResult.REJECTED
['isAnonymous()'] | [SecurityRule.IS_ANONYMOUS] | SecurityRuleResult.ALLOWED
['isAuthenticated()'] | [SecurityRule.IS_AUTHENTICATED] | SecurityRuleResult.ALLOWED
description = expected == SecurityRuleResult.ALLOWED ? 'Allowed' : 'Rejected'
}

def 'configured case-insensitive role comparison applies to intercept url map rules'() {
given:
def securityConfiguration = Stub(SecurityConfiguration) {
getInterceptUrlMap() >> []
isRolesCaseSensitive() >> false
}
RolesFinder caseInsensitiveRolesFinder = new DefaultRolesFinder(tokenConfiguration, securityConfiguration)
ConfigurationInterceptUrlMapRule provider = new ConfigurationInterceptUrlMapRule(caseInsensitiveRolesFinder, securityConfiguration, new DefaultInterceptUrlPatternModifier(securityConfiguration, () -> null))

expect:
SecurityRuleResult.ALLOWED == Mono.from(provider.compareRoles(['role_admin'], ['ROLE_ADMIN'])).block()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.micronaut.security.token

import io.micronaut.security.config.SecurityConfiguration
import io.micronaut.security.authentication.AuthenticationMode
import io.micronaut.security.token.config.TokenConfiguration
import spock.lang.Specification
import spock.lang.Unroll

class DefaultRolesFinderSpec extends Specification {

TokenConfiguration tokenConfiguration = new TokenConfiguration() {}

void "default constructor keeps role comparison case sensitive"() {
given:
DefaultRolesFinder rolesFinder = new DefaultRolesFinder(tokenConfiguration)

expect:
rolesFinder.hasAnyRequiredRoles(['ROLE_ADMIN'], ['ROLE_ADMIN'])
!rolesFinder.hasAnyRequiredRoles(['role_admin'], ['ROLE_ADMIN'])
}

@Unroll
void "configured case insensitive role comparison for #requiredRoles and #grantedRoles is #expected"() {
given:
SecurityConfiguration securityConfiguration = Stub(SecurityConfiguration) {
isRolesCaseSensitive() >> false
}
DefaultRolesFinder rolesFinder = new DefaultRolesFinder(tokenConfiguration, securityConfiguration)

expect:
rolesFinder.hasAnyRequiredRoles(requiredRoles, grantedRoles) == expected

where:
requiredRoles | grantedRoles || expected
['role_admin'] | ['ROLE_ADMIN'] || true
['role_admin'] | ['ROLE_USER'] || false
[] | ['ROLE_ADMIN'] || false
['role_admin'] | [] || false
[null] | [null] || true
[null, 'role_admin'] | ['ROLE_ADMIN'] || true
}

void "security configuration roles case-sensitive default is true"() {
given:
SecurityConfiguration securityConfiguration = new SecurityConfiguration() {
@Override
List<String> getIpPatterns() {
[]
}

@Override
List getInterceptUrlMap() {
[]
}

@Override
boolean isInterceptUrlMapPrependPatternWithContextPath() {
true
}

@Override
AuthenticationMode getAuthentication() {
null
}
}

expect:
securityConfiguration.rolesCaseSensitive
}
}
1 change: 1 addition & 0 deletions src/main/docs/guide/securityConfiguration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ The following global configuration options are available:

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

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.
Loading