⬆️ ⬇️

Blend @PreAuthorize in Spring Security with arbitrary types and simple inspected DSL

Spring Security is a must-have component in Spring-based applications, as it is responsible for authenticating the user, as well as authorizing some of his actions in the system. One of the authorization methods in Spring Security is to use the @PreAuthorize annotation, in which using expressions you can visually describe the rules, following which the authorization module decides whether to allow the operation or prohibit it.



In my REST service, it became necessary to provide an access point to the description of authorization rules for all methods of service controllers. And, if possible, avoid revealing the specifics of SpEL expressions (i.e., instead of permitAll , something like anybody needed, and the principal should be avoided as a redundant expression), but return your expressions with which you can do anything.





Start



Let's imagine a small service and rules for access to it.





 public interface IGreetingService { @Nonnull String sayHelloTo(@Nonnull String name); @Nonnull String sayGoodByeTo(@Nonnull String name); } 




 @Service public final class GreetingService implements IGreetingService { @Override @Nonnull @PreAuthorize("@A.maySayHelloTo(principal, #name)") public String sayHelloTo( @P("name") @Nonnull final String name ) { return "hello " + name; } @Nonnull @Override @PreAuthorize("@A.maySayGoodByeTo(principal, #name)") public String sayGoodByeTo( @P("name") @Nonnull final String name ) { return "good bye" + name; } } 




 public interface IAuthorizationComponent { boolean maySayHelloTo(@Nonnull UserDetails principal, @Nonnull String name); boolean maySayGoodByeTo(@Nonnull UserDetails principal, @Nonnull String name); } 




 @Component("A") public final class AuthorizationComponent implements IAuthorizationComponent { @Override public boolean maySayHelloTo(@Nonnull final UserDetails principal, @Nonnull final String name) { return true; } @Override public boolean maySayGoodByeTo(@Nonnull final UserDetails principal, @Nonnull final String name) { return false; } } 


From boolean to expressions



Having only the result of logical expressions available, one cannot get descriptions of the rule itself. It is necessary to somehow determine which rules are responsible for a specific action. Suppose we decide to use not a boolean , but an object that describes the rule at a higher level. We would have only two requirements for such an object:





Based on the requirements, it is enough for us to have something of the type at your disposal:



 public interface IAuthorizationExpression { boolean mayProceed(); @Nonnull String toHumanReadableExpression(); } 


And slightly change the authorization component:



 public interface IAuthorizationComponent { @Nonnull IAuthorizationExpression maySayHelloTo(@Nonnull UserDetails principal, @Nonnull String name); @Nonnull IAuthorizationExpression maySayGoodByeTo(@Nonnull UserDetails principal, @Nonnull String name); } 


 @Component("A") public final class AuthorizationComponent implements IAuthorizationComponent { @Nonnull @Override public IAuthorizationExpression maySayHelloTo(@Nonnull final UserDetails principal, @Nonnull final String name) { return simpleAuthorizationExpression(true); } @Nonnull @Override public IAuthorizationExpression maySayGoodByeTo(@Nonnull final UserDetails principal, @Nonnull final String name) { return simpleAuthorizationExpression(true); } } 




 public final class SimpleAuthorizationExpression implements IAuthorizationExpression { private static final IAuthorizationExpression mayProceedExpression = new SimpleAuthorizationExpression(true); private static final IAuthorizationExpression mayNotProceedExpression = new SimpleAuthorizationExpression(false); private final boolean mayProceed; private SimpleAuthorizationExpression(final boolean mayProceed) { this.mayProceed = mayProceed; } public static IAuthorizationExpression simpleAuthorizationExpression(final boolean mayProceed) { return mayProceed ? mayProceedExpression : mayNotProceedExpression; } public boolean mayProceed() { return mayProceed; } @Nonnull public String toHumanReadableExpression() { return mayProceed ? "TRUE" : "FALSE"; } } 


Unfortunately, in the normal mode, @PreAuthorize works in such a way that its expressions can only return boolean values. Therefore, when accessing the service methods, the following exception will occur:



 Exception in thread "main" java.lang.IllegalArgumentException: Failed to evaluate expression '@A.maySayHelloTo(principal, #name)' at org.springframework.security.access.expression.ExpressionUtils.evaluateAsBoolean(ExpressionUtils.java:30) at org.springframework.security.access.expression.method.ExpressionBasedPreInvocationAdvice.before(ExpressionBasedPreInvocationAdvice.java:59) at org.springframework.security.access.prepost.PreInvocationAuthorizationAdviceVoter.vote(PreInvocationAuthorizationAdviceVoter.java:72) at org.springframework.security.access.prepost.PreInvocationAuthorizationAdviceVoter.vote(PreInvocationAuthorizationAdviceVoter.java:40) at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:63) at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:233) at org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor.invoke(MethodSecurityInterceptor.java:65) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:213) at com.sun.proxy.$Proxy38.sayHelloTo(Unknown Source) at test.springfx.security.app.Application.lambda$main$0(Application.java:23) at test.springfx.security.app.Application$$Lambda$7/2043106095.run(Unknown Source) at test.springfx.security.fakes.FakeAuthentication.withFakeAuthentication(FakeAuthentication.java:32) at test.springfx.security.app.Application.main(Application.java:23) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:497) at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144) Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1001E:(pos 0): Type conversion problem, cannot convert from @javax.annotation.Nonnull test.springfx.security.app.auth.IAuthorizationExpression$1 to java.lang.Boolean at org.springframework.expression.spel.support.StandardTypeConverter.convertValue(StandardTypeConverter.java:78) at org.springframework.expression.common.ExpressionUtils.convertTypedValue(ExpressionUtils.java:53) at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:301) at org.springframework.security.access.expression.ExpressionUtils.evaluateAsBoolean(ExpressionUtils.java:26) ... 18 more Caused by: org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type [@javax.annotation.Nonnull test.springfx.security.app.auth.IAuthorizationExpression$1] to type [java.lang.Boolean] at org.springframework.core.convert.support.GenericConversionService.handleConverterNotFound(GenericConversionService.java:313) at org.springframework.core.convert.support.GenericConversionService.convert(GenericConversionService.java:195) at org.springframework.expression.spel.support.StandardTypeConverter.convertValue(StandardTypeConverter.java:74) ... 21 more 


Configure @PreAuthorize



First of all, to fix a problem with non-null values, you need to set up GlobalMethodSecurityConfiguration , because it allows you to set the context of expression evaluation in @PreAuthorize . The so-called TypeConverter , which is pretty easy to get to:



 public abstract class CustomTypesGlobalMethodSecurityConfiguration extends GlobalMethodSecurityConfiguration { protected abstract ApplicationContext applicationContext(); protected abstract ConversionService conversionService(); @Override protected MethodSecurityExpressionHandler createExpressionHandler() { final ApplicationContext applicationContext = applicationContext(); final TypeConverter typeConverter = new StandardTypeConverter(conversionService()); final DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler() { @Override public StandardEvaluationContext createEvaluationContextInternal(final Authentication authentication, final MethodInvocation methodInvocation) { final StandardEvaluationContext decoratedStandardEvaluationContext = super.createEvaluationContextInternal(authentication, methodInvocation); return new ForwardingStandardEvaluationContext() { @Override protected StandardEvaluationContext standardEvaluationContext() { return decoratedStandardEvaluationContext; } @Override public TypeConverter getTypeConverter() { return typeConverter; } }; } }; handler.setApplicationContext(applicationContext); return handler; } } 


There are a few points here. First, we will use the standard DefaultMethodSecurityExpressionHandler , which will do most for us, simply by redefining the context returned to them. Secondly, the ancestor of DefaultMethodSecurityExpressionHandler , namely AbstractSecurityExpressionHandler , forbids the creation of its context, but we can create a so-called internal context in which we redefine TypeConverter . Third, we need to write a forwarding decorator for the StandardEvaluationContext so as not to break the behavior of the original context:





 public abstract class ForwardingStandardEvaluationContext extends StandardEvaluationContext { protected abstract StandardEvaluationContext standardEvaluationContext(); // @formatter:off @Override public void setRootObject(final Object rootObject, final TypeDescriptor typeDescriptor) { standardEvaluationContext().setRootObject(rootObject, typeDescriptor); } @Override public void setRootObject(final Object rootObject) { standardEvaluationContext().setRootObject(rootObject); } @Override public TypedValue getRootObject() { return standardEvaluationContext().getRootObject(); } @Override public void addConstructorResolver(final ConstructorResolver resolver) { standardEvaluationContext().addConstructorResolver(resolver); } @Override public boolean removeConstructorResolver(final ConstructorResolver resolver) { return standardEvaluationContext().removeConstructorResolver(resolver); } @Override public void setConstructorResolvers(final List<ConstructorResolver> constructorResolvers) { standardEvaluationContext().setConstructorResolvers(constructorResolvers); } @Override public List<ConstructorResolver> getConstructorResolvers() { return standardEvaluationContext().getConstructorResolvers(); } @Override public void addMethodResolver(final MethodResolver resolver) { standardEvaluationContext().addMethodResolver(resolver); } @Override public boolean removeMethodResolver(final MethodResolver methodResolver) { return standardEvaluationContext().removeMethodResolver(methodResolver); } @Override public void setMethodResolvers(final List<MethodResolver> methodResolvers) { standardEvaluationContext().setMethodResolvers(methodResolvers); } @Override public List<MethodResolver> getMethodResolvers() { return standardEvaluationContext().getMethodResolvers(); } @Override public void setBeanResolver(final BeanResolver beanResolver) { standardEvaluationContext().setBeanResolver(beanResolver); } @Override public BeanResolver getBeanResolver() { return standardEvaluationContext().getBeanResolver(); } @Override public void addPropertyAccessor(final PropertyAccessor accessor) { standardEvaluationContext().addPropertyAccessor(accessor); } @Override public boolean removePropertyAccessor(final PropertyAccessor accessor) { return standardEvaluationContext().removePropertyAccessor(accessor); } @Override public void setPropertyAccessors(final List<PropertyAccessor> propertyAccessors) { standardEvaluationContext().setPropertyAccessors(propertyAccessors); } @Override public List<PropertyAccessor> getPropertyAccessors() { return standardEvaluationContext().getPropertyAccessors(); } @Override public void setTypeLocator(final TypeLocator typeLocator) { standardEvaluationContext().setTypeLocator(typeLocator); } @Override public TypeLocator getTypeLocator() { return standardEvaluationContext().getTypeLocator(); } @Override public void setTypeConverter(final TypeConverter typeConverter) { standardEvaluationContext().setTypeConverter(typeConverter); } @Override public TypeConverter getTypeConverter() { return standardEvaluationContext().getTypeConverter(); } @Override public void setTypeComparator(final TypeComparator typeComparator) { standardEvaluationContext().setTypeComparator(typeComparator); } @Override public TypeComparator getTypeComparator() { return standardEvaluationContext().getTypeComparator(); } @Override public void setOperatorOverloader(final OperatorOverloader operatorOverloader) { standardEvaluationContext().setOperatorOverloader(operatorOverloader); } @Override public OperatorOverloader getOperatorOverloader() { return standardEvaluationContext().getOperatorOverloader(); } @Override public void setVariable(final String name, final Object value) { standardEvaluationContext().setVariable(name, value); } @Override public void setVariables(final Map<String, Object> variables) { standardEvaluationContext().setVariables(variables); } @Override public void registerFunction(final String name, final Method method) { standardEvaluationContext().registerFunction(name, method); } @Override public Object lookupVariable(final String name) { return standardEvaluationContext().lookupVariable(name); } @Override public void registerMethodFilter(final Class<?> type, final MethodFilter filter) throws IllegalStateException { standardEvaluationContext().registerMethodFilter(type, filter); } // @formatter:on } 


Yes, Java doesn’t look better here, and it would be great if Java knew how by Kotlin, so that you don’t have to write so much. Or, for example, @Delegate from Lombok. Of course, it was possible to use the field, not an abstract method, but the abstract method seems to me a bit more flexible (however, I don’t know if Kotlin and Lombok can delegate to the method that returns the object being decorated).



I would take the two classes above from the “library” layer, i.e. It can be used in several applications separately and can be customized for each application. And now in the “application layer” you can now easily add your converter from IAuthorizationExpression to boolean :





 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = false) public class SecurityConfiguration extends CustomTypesGlobalMethodSecurityConfiguration { private final ApplicationContext applicationContext; private final ConversionService conversionService; public SecurityConfiguration( @Autowired final ApplicationContext applicationContext, @Autowired final ConversionService conversionService ) { this.applicationContext = applicationContext; this.conversionService = conversionService; } @Override protected ApplicationContext applicationContext() { return applicationContext; } @Override protected ConversionService conversionService() { return conversionService; } } 




 @Configuration public class ConversionConfiguration { @Bean public ConversionService conversionService() { final DefaultConversionService conversionService = new DefaultConversionService(); conversionService.addConverter(IAuthorizationExpression.class, Boolean.class, IAuthorizationExpression::mayProceed); return conversionService; } } 


Now IAuthorizationExpression works as a boolean and can participate in logical operations directly.



More complex expressions



Since we already have an expression object available, we can extend its basic functionality and create expressions that are more complicated than SimpleAuthorizationExpression . This will allow us to combine expressions of any complexity, while observing the requirements mentioned above.



I have always liked fluent interfaces, whose methods can be combined conveniently. For example, Mockito and Hamcrest use this approach with might and main. And Java is now also for basic interfaces such as Function , Supplier , Consumer , Comparator , etc. Java 8 introduced a bunch of good features into the language, and one of them, namely the default methods, can be used to extend the basic functionality of expressions. For example, you can add a simple AND predicate to IAuthorizationExpression :



 default IAuthorizationExpression and(final IAuthorizationExpression r) { return new IAuthorizationExpression() { @Override public boolean mayProceed() { return IAuthorizationExpression.this.mayProceed() && r.mayProceed(); } @Nonnull @Override public String toHumanReadableExpression() { return new StringBuilder("(") .append(IAuthorizationExpression.this.toHumanReadableExpression()) .append(" AND ") .append(r.toHumanReadableExpression()) .append(')') .toString(); } }; } 


As you can see, the AND operation is very simple. In the mayProceed method, mayProceed can simply get the result of the composition of expressions using && , and in toHumanReadableExpression , you can form a string just for this expression. Now you can combine an expression using the AND operation, for example, like this:



 simpleAuthorizationExpression(true).and(simpleAuthorizationExpression(true)) 


And at the same time the string representation for such an expression will be:



 (TRUE AND TRUE) 


Very good. You can also add support for the OR operation or the unary NOT operation without problems. In addition, you can create more complex expressions without trivial operations, since SimpleAuthorizationExpression does not make much sense. For example, an expression that determines whether the user is root:



 public final class IsRootAuthorizationExpression implements IAuthorizationExpression { private final UserDetails userDetails; private IsRootAuthorizationExpression(final UserDetails userDetails) { this.userDetails = userDetails; } public static IAuthorizationExpression isRoot(final UserDetails userDetails) { return new IsRootAuthorizationExpression(userDetails); } @Override public boolean mayProceed() { return Objects.equals(userDetails.getUsername(), "root"); } @Nonnull @Override public String toHumanReadableExpression() { return "isRoot"; } } 


Or whether the string represented by the name variable is forbidden:



 public final class IsNamePermittedAuthorizationExpression implements IAuthorizationExpression { private static final Collection<String> bannedStrings = emptyList(); private final String name; private IsNamePermittedAuthorizationExpression(final String name) { this.name = name; } public static IAuthorizationExpression isNamePermitted(final String name) { return new IsNamePermittedAuthorizationExpression(name); } @Override public boolean mayProceed() { return !bannedStrings.contains(name.toLowerCase()); } @Nonnull @Override public String toHumanReadableExpression() { return new StringBuilder() .append("(name NOT IN (") .append(bannedStrings.stream().collect(joining())) .append("))") .toString(); } } 


Authorization rules can now be presented differently:



 @Nonnull @Override public IAuthorizationExpression maySayHelloTo(@Nonnull final UserDetails principal, @Nonnull final String name) { return isNamePermitted(name); } @Nonnull @Override public IAuthorizationExpression maySayGoodByeTo(@Nonnull final UserDetails principal, @Nonnull final String name) { return isRoot(principal).and(isNamePermitted(name)); } 


The code is quite readable and it is perfectly clear what exactly these expressions do. This is how the string representations themselves look like:





The analogy on the face, right?



Convert @PreAuthorize to IAuthorizationExpression and string representation



Now it only remains to get these expressions at runtime. Suppose there is a separate opportunity to get a list of all the methods that we need (there may be a lot of specifics, and we are not really interested in it now). Having such a set of methods, it remains just to “virtually” execute expressions from @PreAuthorize , replacing the missing variables with some values. For example:



 @Service public final class DiscoverService implements IDiscoverService { private static final UserDetails userDetailsMock = (UserDetails) newProxyInstance( DiscoverService.class.getClassLoader(), new Class<?>[]{ UserDetails.class }, (proxy, method, args) -> { throw new AssertionError(method); } ); private static final Authentication authenticationMock = (Authentication) newProxyInstance( DiscoverService.class.getClassLoader(), new Class<?>[]{ Authentication.class }, (proxy, method, args) -> { switch ( method.getName() ) { case "getPrincipal": return userDetailsMock; case "isAuthenticated": return true; default: throw new AssertionError(method); } } ); private final ApplicationContext applicationContext; private final ConversionService conversionService; public DiscoverService( @Autowired final ApplicationContext applicationContext, @Autowired final ConversionService conversionService ) { this.applicationContext = applicationContext; this.conversionService = conversionService; } @Override @Nullable public <T> String toAuthorizationExpression(@Nonnull final T object, @Nonnull final Class<? extends T> inspectType, @Nonnull final String methodName, @Nonnull final Class<?>... parameterTypes) throws NoSuchMethodException { final Method method = inspectType.getMethod(methodName, parameterTypes); final DefaultMethodSecurityExpressionHandler expressionHandler = createMethodSecurityExpressionHandler(); final MethodInvocation invocation = createMethodInvocation(object, method); final EvaluationContext evaluationContext = createEvaluationContext(method, expressionHandler, invocation); final Object value = evaluate(method, evaluationContext); return resolveAsString(value); } private DefaultMethodSecurityExpressionHandler createMethodSecurityExpressionHandler() { final DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); expressionHandler.setApplicationContext(applicationContext); return expressionHandler; } private <T> MethodInvocation createMethodInvocation(@Nonnull final T object, final Method method) { final Parameter[] parameters = method.getParameters(); return new SimpleMethodInvocation(object, method, Stream.of(parameters).map(<...>).toArray(Object[]::new)); } private EvaluationContext createEvaluationContext(final Method method, final SecurityExpressionHandler<MethodInvocation> expressionHandler, final MethodInvocation invocation) { final EvaluationContext decoratedExpressionContext = expressionHandler.createEvaluationContext(authenticationMock, invocation); final TypeConverter typeConverter = new StandardTypeConverter(conversionService); return new ForwardingEvaluationContext() { @Override protected EvaluationContext evaluationContext() { return decoratedExpressionContext; } @Override public TypeConverter getTypeConverter() { return typeConverter; } @Override public Object lookupVariable(final String name) { return <...>; } }; } private static Object evaluate(final Method method, final EvaluationContext evaluationContext) { final ExpressionParser parser = new SpelExpressionParser(); final PreAuthorize preAuthorizeAnnotation = method.getAnnotation(PreAuthorize.class); final Expression expression = parser.parseExpression(preAuthorizeAnnotation.value()); return expression.getValue(evaluationContext, Object.class); } private static String resolveAsString(final Object value) { if ( value instanceof IAuthorizationExpression ) { return ((IAuthorizationExpression) value).toHumanReadableExpression(); } return String.valueOf(value); } } 


This code is a bit more complicated, but in reality there is nothing complicated about it. Difficulties may arise unless finding the missing variables in for expressions (for example, #name from the examples above). Instead of <...> you need to substitute your own implementation for argument substitution in parameters. In fact, you can often get by with just a null , but in some cases this solution does not work for obvious reasons. And one more not very pleasant feature: you need to manually create another ForwardingEvaluationContext by the same principle as the ForwardingStandardEvaluationContext above:



 public abstract class ForwardingEvaluationContext implements EvaluationContext { protected abstract EvaluationContext evaluationContext(); // @formatter:off @Override public TypedValue getRootObject() { return evaluationContext().getRootObject(); } @Override public List<ConstructorResolver> getConstructorResolvers() { return evaluationContext().getConstructorResolvers(); } @Override public List<MethodResolver> getMethodResolvers() { return evaluationContext().getMethodResolvers(); } @Override public List<PropertyAccessor> getPropertyAccessors() { return evaluationContext().getPropertyAccessors(); } @Override public TypeLocator getTypeLocator() { return evaluationContext().getTypeLocator(); } @Override public TypeConverter getTypeConverter() { return evaluationContext().getTypeConverter(); } @Override public TypeComparator getTypeComparator() { return evaluationContext().getTypeComparator(); } @Override public OperatorOverloader getOperatorOverloader() { return evaluationContext().getOperatorOverloader(); } @Override public BeanResolver getBeanResolver() { return evaluationContext().getBeanResolver(); } @Override public void setVariable(final String name, final Object value) { evaluationContext().setVariable(name, value); } @Override public Object lookupVariable(final String name) { return evaluationContext().lookupVariable(name); } // @formatter:on } 


: @PreAuthorize , .





, , , . Springmvc-router , . , , , . , @PreAuthorize , .



- :





“Effective Java” — . final nullability- — , — .



And the last. @PreAuthorize . -, , , ( , ). -, . -, , @PreAuthorize . , , . IDE .



')

Source: https://habr.com/ru/post/307558/



All Articles