Add support for sec:authorize attribute of Thymeleaf (#7322)

#### What type of PR is this?

/kind improvement
/area core
/area theme
/milestone 2.20.x

#### What this PR does / why we need it:

This PR adds support for sec:authorize attribute of Thymeleaf which is not supported yet. See https://github.com/halo-dev/halo/issues/7316 for more.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7316

#### Does this PR introduce a user-facing change?

```release-note
完善主题模板判断用户角色等功能
```
pull/7341/head
John Niang 2025-04-11 10:48:00 +08:00 committed by GitHub
parent 629a0f893e
commit 067e3d58e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 198 additions and 3 deletions

View File

@ -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 {

View File

@ -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<MethodSecurityExpressionHandler> expressionHandler) {
return new HaloSpringSecurityDialect(securityContextRepository, expressionHandler);
}
@Bean

View File

@ -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<MethodSecurityExpressionHandler> expressionHandler;
public HaloSpringSecurityDialect(ServerSecurityContextRepository securityContextRepository,
ObjectProvider<MethodSecurityExpressionHandler> 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<IProcessor> getProcessors(String dialectPrefix) {
LinkedHashSet<IProcessor> 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");
}
}
}

View File

@ -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<MethodSecurityExpressionHandler> expressionHandler;
@BeforeEach
void setUp() {
var haloSpringSecurityDialect =
new HaloSpringSecurityDialect(securityContextRepository, expressionHandler);
templateEngine = new SpringWebFluxTemplateEngine();
templateEngine.addTemplateResolver(new StringTemplateResolver());
templateEngine.addDialect(haloSpringSecurityDialect);
}
static Stream<Arguments> shouldEvaluateSecAuthorizeAttr() {
return Stream.of(
arguments(
"Evaluate sec:authorize to true when role match",
List.of("ROLE_ADMIN"),
"""
<p sec:authorize="hasRole('ROLE_ADMIN')">Admin</p>\
""",
"""
<p>Admin</p>\
"""),
arguments(
"Evaluate sec:authorize to false when role not match",
List.of("ROLE_USER"),
"""
<p sec:authorize="hasRole('ROLE_ADMIN')"></p>\
""",
"")
);
}
@ParameterizedTest(name = "{0}")
@MethodSource
void shouldEvaluateSecAuthorizeAttr(String name, List<String> 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);
}
}