Spring Security PreAuthorize ROLE无法获取JWT声明。

huangapple go评论148阅读模式
英文:

Spring Security PreAuthorize ROLE does not pick up JWT claim

问题

我已经实现了一个简单的OAuth2测试应用程序(授权服务器,资源服务器,客户端),基于Baeldung示例(示例代码在GitHub上)。

一切运行正常,但我想添加一个额外的安全约束

  • 到目前为止,资源服务器通过JWT检查来验证客户端的SCOPE
  • 我希望资源服务器也检查授权客户端的用户的**ROLE**。

我所做的:

  • 默认情况下,JWT中没有ROLE信息,因此我在授权服务器中添加了它。它现在添加了一个新的claim "auth"(不确定"auth"是否是正确的关键字):
  1. @Bean
  2. OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
  3. return context -> {
  4. if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
  5. Authentication principal = context.getPrincipal();
  6. String enumeratedRoles = principal.getAuthorities().iterator().next().getAuthority();
  7. context.getClaims().claim("auth", enumeratedRoles);
  8. }
  9. };
  10. }
  • 我调试了资源服务器调用的受保护方法(由客户端调用)。JWT和authorized.principal都携带了额外的claim,到目前为止一切正常:

    • JWT载荷,反序列化:
  1. {
  2. "sub":"AssortmentExtender",
  3. "aud":"assortment-client",
  4. "nbf":1689578763,
  5. "auth":"ROLE_ADMIN",
  6. "scope":[
  7. "assortment.write"
  8. ],
  9. "iss":"http://auth-server:9000",
  10. "exp":1689579063,
  11. "iat":1689578763
  12. }
  • 受保护方法,调试信息:
  1. @PreAuthorize("hasRole('ADMIN')")
  2. @PutMapping("/bookstore/isbns/{isbn}")
  3. public void addBookToAssortment(@RequestBody BookDetailsImpl bookDetails, final @AuthenticationPrincipal
  4. Jwt jwt, Authentication authentication) {
  5. // 保护逻辑,这里我调试过了...
  6. }

调试器字段,对于方法的authentication参数正确显示了ROLE_ADMIN claim:

  1. name = "AssortmentExtender"
  2. ...
  3. principal = {Jwt@7362}
  4. headers = {Collections$UnmodifiableMap@7402\ size = 2
  5. claims = {Collections$UnmodifiableMap@7403} size = 8
  6. "sub" -> "AssortmentExtender"
  7. "aud" -> {ArrayList@7428} size = 1
  8. "nbf" -> Instant@74301 #2023-07-17T07:15:33Z
  9. "auth" -> "ROLE_ADMIN"
  10. "scope" -> {ArrayList@7434} size = 1
  11. "iss" -> "htto://auth-server:9000"
  12. "exp" -> {Instant@7406) "2023-07-17T07:20:33Z"
  13. "iat" -> (Instant@7405) "2023-07-17T07:15:33Z"
  14. tokenValue = "eyJrawQiOil3YTzNTNmOCOxYTAXLTQwNmQtYjczOCthN
  15. issuedAt = (Instant@7405) "2023-07-17T07:15:33Z"
  16. expiresAt = {Instant@74061 2023-07-17T07:20:33Z"
  17. credentials = {Jwt@7362}

不起作用的部分:

  • 资源服务器的@PreAuthorize拒绝了客户端请求。
  • 日志显示:
  1. Failed to authorize ReflectiveMethodInvocation: [...] with authorization manager
  2. org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@4b97e0b0 and decision ExpressionAuthorizationDecision [granted=false,
  3. expressionAttribute=hasRole('ADMIN')]

总结:

  • Claim是存在的,JWT和principal对象正确显示了ADMIN_ROLE claim。
  • @PreAuthorize仍然拒绝请求。

问题:

  • 为什么PreAuthorize没有获取到'auth' claim?
  • 我是否只是在使用错误的关键字?@PreAuthorize是否检查的是主体声明之外的其他内容?(我已经尝试了各种变体,包括这里讨论的

编辑:

  • 我目前找到的问题是@PreAuthorize实际上并没有访问声明,而是从JWT结构中提取的“权限(authorities)”。默认情况下,它只提取作用域,并在每个条目前加上“SCOPE_”前缀。
  • 我的猜测是我可能需要注册一个机制,也可以对我的JWT角色执行相同的操作,似乎可以通过自定义的JwtAuthenticationConverter来实现。然而,由于SecurityConfigjwt().jwtAuthenticationConverter(...)附录似乎现在已经过时了,我对如何实现此操作感到迷失。

编辑2:

  1. private Converter getJwtAuthenticationConverter() {
  2. // 创建一个自定义的JWT转换器,将token中的“roles”映射为授予的权限
  3. JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
  4. new JwtGrantedAuthoritiesConverter();
  5. jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); // 在JWT中作为JSON条目的声明
  6. jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
  7. "ROLE_"); // 在权限对象中要使用的前缀
  8. // 返回一个新的转换器对象,反映上述JWT声明到权限的规则。
  9. JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
  10. jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter
  11. <details>
  12. <summary>英文:</summary>
  13. I have implemented a simple OAuth2 test app (Authorization Server, Resource Server, Client), based on [a Baeldung example](https://www.baeldung.com/spring-security-oauth-auth-server) (Sample code is on [GitHub](https://github.com/Baeldung/spring-security-oauth/tree/master/oauth-authorization-server))
  14. Everything works fine, but **I want to add an *additional* security contraint**:
  15. - So far the ResourceServer verifies the Clients `SCOPE` (by JWT inspection).
  16. - I want the ResourceServer to also check the **`ROLE` of the user who authorized the client**.
  17. **What I did:**
  18. * By default the ROLE information is not as claim in the JWT, so I added it (in the Authorization Server). It now adds new claim auth (unsure if `auth` is the right keyword):
  19. ```java
  20. @Bean
  21. OAuth2TokenCustomizer&lt;JwtEncodingContext&gt; jwtCustomizer() {
  22. return context -&gt; {
  23. if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
  24. Authentication principal = context.getPrincipal();
  25. String enumeratedRoles = principal.getAuthorities().iterator().next().getAuthority();
  26. context.getClaims().claim(&quot;auth&quot;, enumeratedRoles);
  27. }
  28. };
  29. }
  • I debugged the ResourceServer's portected method (the one invoked by client). Both, JWT and authorized.principal carry the additional claim, so far so good:
    • JWT bayload, deserialized:
  1. {
  2. &quot;sub&quot;:&quot;AssortmentExtender&quot;,
  3. &quot;aud&quot;:&quot;assortment-client&quot;,
  4. &quot;nbf&quot;:1689578763,
  5. &quot;auth&quot;:&quot;ROLE_ADMIN&quot;,
  6. &quot;scope&quot;:[
  7. &quot;assortment.write&quot;
  8. ],
  9. &quot;iss&quot;:&quot;http://auth-server:9000&quot;,
  10. &quot;exp&quot;:1689579063,
  11. &quot;iat&quot;:1689578763
  12. }
  • Protected method, debug info:
  1. @PreAuthorize(&quot;hasRole(&#39;ADMIN&#39;)&quot;)
  2. @PutMapping(&quot;/bookstore/isbns/{isbn}&quot;)
  3. public void addBookToAssortment(@RequestBody BookDetailsImpl bookDetails, final @AuthenticationPrincipal
  4. Jwt jwt, Authentication authentication) {
  5. // Whatever protected logic, here I debugged...
  6. }

Debugger fields, for method's authentication parameter correctly shows the ROLE_ADMIN claim:

  1. name = &quot;AssortmentExtender&quot;
  2. ...
  3. principal = {Jwt@7362}
  4. headers = {Collections$UnmodifiableMap@7402\ size = 2
  5. claims = {Collections$UnmodifiableMap@7403} size = 8
  6. &quot;sub&quot; -&gt; &quot;AssortmentExtender&quot;
  7. &quot;aud&quot; -&gt; {ArrayList@7428} size = 1
  8. &quot;nbf&quot; -&gt; Instant@74301 #2023-07-17T07:15:33Z&quot;
  9. &quot;auth&quot; -&gt; &quot;ROLE_ADMIN&quot;
  10. &quot;scope&quot; -&gt; {ArrayList@7434} size = 1
  11. &quot;iss&quot; -&gt; &quot;htto://auth-server:9000&quot;
  12. &quot;exp&quot; -&gt; {Instant@7406) &quot;2023-07-17T07:20:33Z&quot;
  13. &quot;iat&quot; -&gt; (Instant@7405) &quot;2023-07-17T07:15:33Z&quot;
  14. tokenValue = &quot;eyJrawQiOil3YTzNTNmOCOxYTAXLTQwNmQtYjczOCthN
  15. issuedAt = (Instant@7405) &quot;2023-07-17T07:15:33Z&quot;
  16. expiresAt = {Instant@74061 2023-07-17T07:20:33Z&quot;
  17. credentials = {Jwt@7362}

What does not work:

  • The ResourceServer's @PreAuthorize rejects the Client request.
  • Logger says:
  1. Failed to authorize ReflectiveMethodInvocation: [...] with authorization manager
  2. org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@4b97e0b0 and decision ExpressionAuthorizationDecision [granted=false,
  3. expressionAttribute=hasRole(&#39;ADMIN&#39;)]

Summary:

  • The claim is there, JWT and principal object correctly show the ADMIN_ROLE claim.
  • @PreAuthorize rejects the request anyway.

Question:

  • Why does PreAuthorize not pick up the 'auth' claim?
  • Am I just using the wrong keyword? Is @PreAuthorize checking something else than the principal claims? 
(I've already tried various variants, including role, roles, as discussed here)

EDIT:

  • From what I've found so far, the problem is that @PreAuthorize does not acutally access the claims, but the authorities extracted from the JWT structure. By default it only extracts scopes, and prefixes each entry with SCOPE_.
  • My guess is that I somehow need to register a mechanism that does now the same for my jwt ROLES, and this seems to be possible with a custom JwtAuthenticationConverter. However, I am lost as of how to achieve this, notably since the jwt().jwtAuthenticationConverter(...) addendum for the SecurityConfig appears to be deprecated now.

EDIT2

  1. private Converter getJwtAuthenticationConverter() {
  2. // create a custom JWT converter to map the &quot;roles&quot; from the token as granted authorities
  3. JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
  4. new JwtGrantedAuthoritiesConverter();
  5. jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(&quot;roles&quot;); // claim as JSON entry in JWT
  6. jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
  7. &quot;ROLE_&quot;); // prefix to be used in authority object
  8. // Return a new Converter object that reflects the above JWTclaim-To-Authorities rules.
  9. JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
  10. jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
  11. return jwtAuthenticationConverter;
  12. }
  • However unfortunately this seems to overwrite all existing authorities from the scope claims.
  • Based on a github discussion, I tried to write a converter that combines the existing scope rules with the custom roles claim, but unfortunatley this throws a cast exception at runtime:
  1. // Throws classcast exception at runtime:
  2. // java.util.LinkedHashSet cannot be cast to class org.springframework.security.authentication.AbstractAuthenticationToken
  3. private Converter getDualJwtAuthenticationConverter() {
  4. JwtGrantedAuthoritiesConverter scope = new JwtGrantedAuthoritiesConverter();
  5. scope.setAuthorityPrefix(&quot;SCOPE_&quot;);
  6. scope.setAuthoritiesClaimName(&quot;scope&quot;);
  7. JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
  8. roles.setAuthorityPrefix(&quot;ROLE_&quot;);
  9. roles.setAuthoritiesClaimName(&quot;roles&quot;);
  10. return new DelegatingJwtGrantedAuthoritiesConverter(scope, roles);
  11. }

As far as I can tell, the problem is that the Security configuration jwt.jwtAuthenticationConverter(whateverConverter()); wants a converter of type &lt;Jwt, AbstractAuthToken&gt;, while the DelegatingJwtGrantedAuthoritiesConverter is of type &lt;Jwt, Collection&lt;GrantedAuthority&gt;&gt;.

So ultimately the question is how to write a converter that combines multiple JWT claims into a fused list of authorities.

答案1

得分: 1

关于JWT内容的部分:

在JWT的声明部分,我们还会自己填充一个authorities字段。

配置身份验证转换器的部分:

这段代码并没有被废弃。API只有稍微变化了一点。

  1. http.oauth2ResourceServer(resourceServer -&gt; {
  2. resourceServer.jwt(jwt -&gt; {
  3. jwt.jwtAuthenticationConverter(converter -&gt; {
  4. // TODO: implement me
  5. return null;
  6. });
  7. });
  8. });

所以你实际上可以以非废弃的方式实现它。

我不知道如何确切地实现这部分,因为我们不使用那些方法,而是自己完成。我们通过提供一个类型为AuthenticationProvider的bean来完成。如果你不需要什么特别的功能,我想你的方法也会起作用,甚至可能更受欢迎(我们的代码是在Spring中进行了现代重写之前的)。

英文:

Not entirely an answer but hopefully enough to get you on the way a bit.

On the topic of the JWT contents:

We also fill an authorities field ourselves in the claims section of the JWT.

On the topic of configuring the authentication converter:

This code isn't deprecated. The API only changed a little bit.

  1. http.oauth2ResourceServer(resourceServer -&gt; {
  2. resourceServer.jwt(jwt -&gt; {
  3. jwt.jwtAuthenticationConverter(converter -&gt; {
  4. // TODO: implement me
  5. return null;
  6. });
  7. });
  8. });

So you can actually still implement that in a non-deprecated way.

I don't know how to exactly implement that part though, since we don't use those methods and just do that ourselves instead. We do it by providing a bean of the type AuthenticationProvider. If you don't need anything fancy, I imagine your way will also work and might even be preferred (our code is from before the modern rewrite of the oauth2 implementation in Spring)

答案2

得分: 0

我找到了如何将多个JWT声明融合成权限列表的方法。

  • 关键是确实要编写一个自定义转换器,正如@Sebastiaan所推荐的。
  • 解决方案与this proposal(相同目标,多个声明的融合)非常接近,尽管我更改了提案以匹配我的JWT结构。
  • 我还为实现添加了JavaDoc注释,以解释转换器代码中实际发生的事情。

实际上,只需要两件事:

  1. 在一个新类中创建您自己的转换器:
  1. import java.util.ArrayList;
  2. import java.util.Collection;
  3. import java.util.stream.Collectors;
  4. import java.util.Collections;
  5. import java.util.stream.Stream;
  6. import org.springframework.core.convert.converter.Converter;
  7. import org.springframework.security.authentication.AbstractAuthenticationToken;
  8. import org.springframework.security.core.GrantedAuthority;
  9. import org.springframework.security.core.authority.SimpleGrantedAuthority;
  10. import org.springframework.security.oauth2.jwt.Jwt;
  11. import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
  12. import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
  13. /**
  14. * 这是一个自定义转换器,用于提取两个Jwt声明“scope”和“role”的所有条目。所有发现的条目都以相应的“SCOPE_”和“ROLE_”前缀开头,然后包装成权限列表,可以由Spring的@PreAuthorize注解处理。
  15. * 此实现基于:https://stackoverflow.com/a/58234971/13805480
  16. */
  17. public class FusedClaimConverter implements Converter<Jwt, AbstractAuthenticationToken> {
  18. // 融合转换器在内部融合了两个转换器的输出。
  19. // 其中一个组件是默认转换器(提取scp/scope声明信息)。
  20. // 因此,我们创建一个此即用即走的转换器实例以供以后使用。
  21. private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter =
  22. new JwtGrantedAuthoritiesConverter();
  23. /**
  24. * 该方法提供了我们自定义转换器的第二个组件。这是一个手动实现,它搜索jwt以查找自定义“role”声明。如果找到,所有条目都将以“ROLE_”前缀返回为权限列表。
  25. *
  26. * @param jwt 用于分析“role”声明条目的JSON Web Token。
  27. * @return 从jwt中提取的授予的权限集合。
  28. */
  29. private static Collection<? extends GrantedAuthority> extractResourceRoles(final Jwt jwt) {
  30. ArrayList<String> resourceAccess = jwt.getClaim(
  31. "role"); // <- 在此处指定要转换为权限的任何其他jwt声明
  32. if (resourceAccess != null) {
  33. // 将“role”声明值列表中的每个条目转换为权限
  34. return resourceAccess.stream().map(x -> new SimpleGrantedAuthority("ROLE_" + x))
  35. .collect(Collectors.toSet());
  36. }
  37. // 回退:如果jwt没有“role”声明,则返回空列表。
  38. return Collections.emptySet();
  39. }
  40. /**
  41. * 这是要覆盖的主要转换器方法。在这里,我们提供了一个自定义实现,该实现连接了从两个不同转换器生成的权限列表。
  42. * 其中一个是默认的转换器,该转换器操作“scp”/“scope”声明。另一个是我们自定义声明的转换器。
  43. *
  44. * @param source 用于检查声明的JSON Web Token。
  45. * @return 从令牌中提取的权限列表,包装在AbstractAuthenticationToken对象中。
  46. */
  47. @Override
  48. public AbstractAuthenticationToken convert(final Jwt source) {
  49. Collection<GrantedAuthority> authorities =
  50. Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
  51. extractResourceRoles(source).stream()).collect(Collectors.toSet());
  52. return new JwtAuthenticationToken(source, authorities);
  53. }
  54. }
  1. 在ResourceServer的SecurityFilterChain中注册您的自定义转换器:
  1. @Configuration
  2. @EnableWebSecurity
  3. @EnableMethodSecurity
  4. public class ResourceServerConfig {
  5. @Bean
  6. SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  7. http
  8. .authorizeHttpRequests((authorize) -> authorize
  9. // 任何自定义规则...
  10. [...]
  11. .oauth2ResourceServer(oauth2 -> {
  12. oauth2.jwt(jwt -> {
  13. jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // <-- 在这里注册自定义转换器。
  14. });
  15. });
  16. return http.build();
  17. }

编辑:
为了完整起见,我已经在GitHub上上传了一个带有此配置的详细记录的可运行应用程序。

英文:

I figured out how to fuse multiple JWT claims to a list of authorities.

  • The trick was indeed to write a custom converter, as recommended by @Sebastiaan.
  • The solution is very close to this proposal (same goal, fusing of multiple claims), although I changed the proposal to match my JWT structure.
  • I also added JavaDoc comments for the implementation, to explain what actually happens in the converter code.

In essence, only two things were needed:

  1. Create you own converter in a new class:
  1. import java.util.ArrayList;
  2. import java.util.Collection;
  3. import java.util.stream.Collectors;
  4. import java.util.Collections;
  5. import java.util.stream.Stream;
  6. import org.springframework.core.convert.converter.Converter;
  7. import org.springframework.security.authentication.AbstractAuthenticationToken;
  8. import org.springframework.security.core.GrantedAuthority;
  9. import org.springframework.security.core.authority.SimpleGrantedAuthority;
  10. import org.springframework.security.oauth2.jwt.Jwt;
  11. import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
  12. import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
  13. /**
  14. * This is a custom converter to extract all entries of two Jwt claims &quot;scope&quot; and &quot;role&quot;. All
  15. * findings are prefixed with the respective &quot;SCOPE_&quot; and &quot;ROLE_&quot; prefixes, and then wrapped up as a
  16. * list of authorities, which can be processed by Springs SPeL in @PreAuthorize annotations. This
  17. * implementation is based on: https://stackoverflow.com/a/58234971/13805480
  18. */
  19. public class FusedClaimConverter implements Converter&lt;Jwt, AbstractAuthenticationToken&gt; {
  20. // The fused converter internally fuses the outputs of two converters.
  21. // One component is the default converter (extracting scp/scope claim information).
  22. // So we create one instance of this off-the-shelf convert for later use.
  23. private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter =
  24. new JwtGrantedAuthoritiesConverter();
  25. /**
  26. * This method provides the second component of our custom converter. It is a manual
  27. * implementation that searches the jwt for a custom &quot;role&quot; claim. If found, all entries are
  28. * prefixed with &quot;ROLE_&quot; and returned as list of authorities.
  29. *
  30. * @param jwt as the json web token to analyze for &quot;role&quot; claim entries.
  31. * @return collection of granted authorities extracted from the jwt.
  32. */
  33. private static Collection&lt;? extends GrantedAuthority&gt; extractResourceRoles(final Jwt jwt) {
  34. ArrayList&lt;String&gt; resourceAccess = jwt.getClaim(
  35. &quot;role&quot;); // &lt;- specify here whatever additional jwt claim you wish to convert to authority
  36. if (resourceAccess != null) {
  37. // Convert every entry in value list of &quot;role&quot; claim to an Authority
  38. return resourceAccess.stream().map(x -&gt; new SimpleGrantedAuthority(&quot;ROLE_&quot; + x))
  39. .collect(Collectors.toSet());
  40. }
  41. // Fallback: return empty list in case the jwt has no &quot;role&quot; claim.
  42. return Collections.emptySet();
  43. }
  44. /**
  45. * This is the main converter method to override. In essence here we provide a custom
  46. * implementation that concatenates the authority lists generated from two respective conterters.
  47. * One is the off-the-shelf default converter that operates on the &quot;scp&quot;/&quot;scope&quot; claim. The other
  48. * is the converter for our custom claim.
  49. *
  50. * @param source as the json web token to inspect for claims
  51. * @return list of authorities extracted from token, wrapped up in AbstractAuthenticationToken
  52. * object.
  53. */
  54. @Override
  55. public AbstractAuthenticationToken convert(final Jwt source) {
  56. Collection&lt;GrantedAuthority&gt; authorities =
  57. Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
  58. extractResourceRoles(source).stream()).collect(Collectors.toSet());
  59. return new JwtAuthenticationToken(source, authorities);
  60. }
  61. }
  1. Register your custom converter in the ResourceServer's SecurityFilterChain:
  1. @Configuration
  2. @EnableWebSecurity
  3. @EnableMethodSecurity
  4. public class ResourceServerConfig {
  5. @Bean
  6. SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  7. http
  8. .authorizeHttpRequests((authorize) -&gt; authorize
  9. // Whatever custom rules...
  10. [...]
  11. .oauth2ResourceServer(oauth2 -&gt; {
  12. oauth2.jwt(jwt -&gt; {
  13. jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // &lt;-- Here the custom converter is registered.
  14. });
  15. });
  16. return http.build();
  17. }

EDIT:
For completeness, I've uploaded a well documented runnable application with this configuration on GitHub.

huangapple
  • 本文由 发表于 2023年7月17日 16:30:53
  • 转载请务必保留本文链接:https://go.coder-hub.com/76702695.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定