diff --git a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java index 9ff7a74b2..14e7e982b 100644 --- a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java @@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.crypto.factory.PasswordEncoderFactories; @@ -44,6 +45,7 @@ import run.halo.app.security.session.ReactiveIndexedSessionRepository; @Configuration @EnableSpringWebSession @EnableWebFluxSecurity +@EnableReactiveMethodSecurity @RequiredArgsConstructor public class WebServerSecurityConfig { diff --git a/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java b/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java index d7a7c784a..38d277c04 100644 --- a/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java +++ b/application/src/main/java/run/halo/app/theme/config/ThemeConfiguration.java @@ -5,6 +5,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; import run.halo.app.theme.dialect.GeneratorMetaProcessor; @@ -26,8 +27,9 @@ public class ThemeConfiguration { @Bean SpringSecurityDialect springSecurityDialect( - ServerSecurityContextRepository securityContextRepository) { - return new HaloSpringSecurityDialect(securityContextRepository); + ServerSecurityContextRepository securityContextRepository, + ObjectProvider expressionHandler) { + return new HaloSpringSecurityDialect(securityContextRepository, expressionHandler); } @Bean diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java index 7c7a44082..8db780ece 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java @@ -1,18 +1,36 @@ package run.halo.app.theme.dialect; import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; +import static org.thymeleaf.extras.springsecurity6.dialect.processor.AuthorizeAttrProcessor.ATTR_NAME; +import static org.thymeleaf.extras.springsecurity6.dialect.processor.AuthorizeAttrProcessor.ATTR_PRECEDENCE; import static run.halo.app.infra.AnonymousUserConst.PRINCIPAL; import static run.halo.app.infra.AnonymousUserConst.Role; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; import java.util.function.Function; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.security.access.expression.ExpressionUtils; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.util.MethodInvocationUtils; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.web.server.ServerWebExchange; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.engine.AttributeName; +import org.thymeleaf.extras.springsecurity6.auth.AuthUtils; import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; +import org.thymeleaf.extras.springsecurity6.util.SpringVersionSpecificUtils; import org.thymeleaf.extras.springsecurity6.util.SpringVersionUtils; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.IProcessor; +import org.thymeleaf.standard.processor.AbstractStandardConditionalVisibilityTagProcessor; +import org.thymeleaf.templatemode.TemplateMode; import run.halo.app.security.authorization.AuthorityUtils; /** @@ -28,8 +46,12 @@ public class HaloSpringSecurityDialect extends SpringSecurityDialect implements private final ServerSecurityContextRepository securityContextRepository; - public HaloSpringSecurityDialect(ServerSecurityContextRepository securityContextRepository) { + private final ObjectProvider expressionHandler; + + public HaloSpringSecurityDialect(ServerSecurityContextRepository securityContextRepository, + ObjectProvider expressionHandler) { this.securityContextRepository = securityContextRepository; + this.expressionHandler = expressionHandler; } @Override @@ -53,4 +75,81 @@ public class HaloSpringSecurityDialect extends SpringSecurityDialect implements // Just overwrite the value of the attribute getExecutionAttributes().put(SECURITY_CONTEXT_EXECUTION_ATTRIBUTE_NAME, secCtxInitializer); } + + @Override + public Set getProcessors(String dialectPrefix) { + LinkedHashSet processors = new LinkedHashSet<>(); + processors.add( + new HaloAuthorizeAttrProcessor(TemplateMode.HTML, dialectPrefix, ATTR_NAME) + ); + processors.addAll(super.getProcessors(dialectPrefix)); + return processors; + } + + public class HaloAuthorizeAttrProcessor + extends AbstractStandardConditionalVisibilityTagProcessor { + + protected HaloAuthorizeAttrProcessor(TemplateMode templateMode, String dialectPrefix, + String attrName) { + super(templateMode, dialectPrefix, attrName, ATTR_PRECEDENCE - 10); + } + + @Override + protected boolean isVisible(ITemplateContext context, IProcessableElementTag tag, + AttributeName attributeName, String attributeValue) { + + final String attrValue = (attributeValue == null ? null : attributeValue.trim()); + + if (attrValue == null || attrValue.isEmpty()) { + return false; + } + + final Authentication authentication = AuthUtils.getAuthenticationObject(context); + + if (authentication == null) { + return false; + } + + // resolve expr + var expr = Optional.of(attributeValue) + .filter(v -> v.startsWith("${") && v.endsWith("}")) + .map(v -> v.substring(2, v.length() - 1)) + .orElse(attributeValue); + + var expressionHandler = HaloSpringSecurityDialect.this.expressionHandler.getIfUnique(); + if (expressionHandler == null) { + // no expression handler found + return false; + } + + var expression = expressionHandler.getExpressionParser().parseExpression(expr); + + var methodInvocation = MethodInvocationUtils.createFromClass(this, + HaloAuthorizeAttrProcessor.class, + "dummyAuthorize", + new Class[] {Authentication.class}, + new Object[] {authentication} + ); + var evaluationContext = + expressionHandler.createEvaluationContext(authentication, methodInvocation); + + var expressionObjects = context.getExpressionObjects(); + var wrappedEvolutionContext = SpringVersionSpecificUtils.wrapEvaluationContext( + evaluationContext, expressionObjects + ); + + return ExpressionUtils.evaluateAsBoolean(expression, wrappedEvolutionContext); + } + + /** + * This method is only used to create a method invocation for the expression parser. + * + * @param authentication authentication object + * @return result of authorization expression evaluation + */ + public Boolean dummyAuthorize(Authentication authentication) { + throw new UnsupportedOperationException("Should not be called"); + } + + } } diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloSpringSecurityDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloSpringSecurityDialectTest.java new file mode 100644 index 000000000..03f8cf221 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloSpringSecurityDialectTest.java @@ -0,0 +1,92 @@ +package run.halo.app.theme.dialect; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.springframework.http.MediaType.TEXT_HTML; + +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.WebContext; +import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; +import org.thymeleaf.spring6.SpringWebFluxTemplateEngine; +import org.thymeleaf.spring6.web.webflux.SpringWebFluxWebApplication; +import org.thymeleaf.templateresolver.StringTemplateResolver; + +// @ExtendWith(MockitoExtension.class) +@SpringBootTest +class HaloSpringSecurityDialectTest { + + TemplateEngine templateEngine; + + @Autowired + ServerSecurityContextRepository securityContextRepository; + + @Autowired + ObjectProvider expressionHandler; + + + @BeforeEach + void setUp() { + var haloSpringSecurityDialect = + new HaloSpringSecurityDialect(securityContextRepository, expressionHandler); + templateEngine = new SpringWebFluxTemplateEngine(); + templateEngine.addTemplateResolver(new StringTemplateResolver()); + templateEngine.addDialect(haloSpringSecurityDialect); + } + + static Stream shouldEvaluateSecAuthorizeAttr() { + return Stream.of( + arguments( + "Evaluate sec:authorize to true when role match", + List.of("ROLE_ADMIN"), + """ +

Admin

\ + """, + """ +

Admin

\ + """), + arguments( + "Evaluate sec:authorize to false when role not match", + List.of("ROLE_USER"), + """ +

\ + """, + "") + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource + void shouldEvaluateSecAuthorizeAttr(String name, List authorities, String template, + String expected) { + var request = MockServerHttpRequest.get("/halo-sec-authorize").build(); + var exchange = new MockServerWebExchange.Builder(request).build(); + var webExchange = SpringWebFluxWebApplication.buildApplication(null) + .buildExchange(exchange, Locale.getDefault(), TEXT_HTML, UTF_8); + var context = new WebContext(webExchange); + var authentication = new UsernamePasswordAuthenticationToken("fake-user", "fake-credential", + AuthorityUtils.createAuthorityList(authorities)); + var securityContext = new SecurityContextImpl(authentication); + context.setVariable(SpringSecurityContextUtils.SECURITY_CONTEXT_MODEL_ATTRIBUTE_NAME, + securityContext); + var result = templateEngine.process(template, context); + assertEquals(expected, result); + } +}