写在前面

本文场景: 希望在网关上实现​​security​​统一进行权限认证,后续的服务间交互不再进行权限认证。但是系统有两个类型的账号,一个是普通用户,一个是系统后台管理员,完全是两个类型,希望创建给两个不同的登陆入口分别给两个类型的账号登录使用。

想到的解决方法有两个:


  1. 网关上的​​security​​想办法创建多个登陆入口(本文描述的方法);
  2. 网关上的​​security​​只对用户进行权限验证,对管理员的请求放行,管理员在自己的模块上进行权限验证。

本文思想: security实现多登陆入口的方法是创建多个​​SecurityFilterChain​​​,即创建多个继承​​WebSecurityConfigurerAdapter​​​的配置类。但是直接创建多个​​SecurityFilterChain​​​并不会直接生效,因为​​SecurityFilterChain​​​默认是全局生效的,且只会执行第一个匹配到的​​SecurityFilterChain​​​,所以在​​configure(HttpSecurity http)​​​方法中需要添加​​requestMatcher​​配置,限制生效范围。

前面3节废话写过滤器,第4节​​Security多登陆入口配置​​直接描述Security的配置。

1. Tomcat中Filter过滤器的执行流程


  1. ​StandardWrapperValve​​的​​invoke​​方法,构造了一个​​ApplicationFilterChain​​对象,并执行了​​doFilter​​方法调用过滤器。​​ApplicationFilterChain​​是​​javax.servlet.FilterChain​​过滤器链的实现,用于管理针对特定请求的一组过滤器的执行,当定义的过滤器全部执行完成后,下一步将开始执行​​Servlet​​本身。
  2. ​doFilter​​方法判断了一下​​SecurityManager​​(Java安全管理器)是否开启,随后调用​​internalDoFilter​​方法,如果​​pos < n​​(过滤器还未全部遍历),则通过​​ApplicationFilterConfig​​获取或者创建过滤器实例,后调用过滤器的​​doFilter(ServletRequest request, ServletResponse response, FilterChain chain)​​方法,如果过滤器调用完成则开始执行​​Servlet​​(​​servlet.service(request, response)​​)。

private void internalDoFilter(ServletRequest request,
ServletResponse response)
throws IOException, ServletException {

// 如果还有未执行的过滤器的话,执行下一项过滤器
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
try {
// 取得或者创建过滤器
Filter filter = filterConfig.getFilter();

if (request.isAsyncSupported() && "false".equalsIgnoreCase(
filterConfig.getFilterDef().getAsyncSupported())) {
request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
}
// Java安全管理器是否开启
if( Globals.IS_SECURITY_ENABLED ) {
final ServletRequest req = request;
final ServletResponse res = response;
Principal principal =
((HttpServletRequest) req).getUserPrincipal();

Object[] args = new Object[]{req, res, this};
SecurityUtil.doAsPrivilege ("doFilter", filter, classType, args, principal);
} else {
filter.doFilter(request, response, this);
}
} catch (IOException | ServletException | RuntimeException e) {
throw e;
} catch (Throwable e) {
e = ExceptionUtils.unwrapInvocationTargetException(e);
ExceptionUtils.handleThrowable(e);
throw new ServletException(sm.getString("filterChain.filter"), e);
}
// 退出当前方法
return;
}

.......
// 如果过滤器全部执行完成将会开始执行这里,忽略n多细节,执行servlet
servlet.service(request, response);
}

​FilterChain​​​将自身当做参数传递给过滤器,​​internalDoFilter​​​并不会再调用和控制过滤器链,而接下来过滤器的遍历操作通过​​filter​​​实例来实现,如果过滤器中调用​​chain.doFilter(request, response)​​​将进行下一个过滤器的遍历操作,也即表示通过当前过滤器,如果没有调用该方法,过滤器链不会继续遍历,也不会执行​​servlet​​,即被过滤器拦截。

2. 常见过滤器类型

GenericFilterBean:**主要是可以把Filter的初始化参数自动地set到继承于GenericFilterBean类的Filter中去。**将web.xml中filter标签中的配置参数​​init-param​​项作为bean的属性,实际的过滤工作留给他的子类来完成。

OncePerRequestFilter:继承​​GenericFilterBean​​​类,主要功能是保证过滤器在任何​​servlet​​​容器上每个请求都只能执行一次。首先通过​​(skipDispatch(httpRequest) || shouldNotFilter(httpRequest))​​​判断是否跳过当前过滤器的执行,直接执行下一个过滤器;然后通过​​hasAlreadyFilteredAttribute​​​属性判断当前过滤器是否已经调用过,确保没有被调用过后执行​​doFilterInternal(httpRequest, httpResponse, filterChain)​​方法实际执行过滤操作。

@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
throw new ServletException("OncePerRequestFilter just supports HTTP requests");
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;

String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
// 判断是否跳过当前过滤器的执行
if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {

// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
// 判断是否跳过当前过滤器是否已经执行过了
else if (hasAlreadyFilteredAttribute) {

if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}

// Proceed without invoking this filter...
filterChain.doFilter(request, response);
}
else {
// 设置alreadyFilteredAttributeName为已经执行过,True
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
// 执行过滤操作
doFilterInternal(httpRequest, httpResponse, filterChain);
}
finally {
// 删除此请求的“已过滤”请求属性
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}

由此可见每个请求都只能执行一次是基于​​request​​的,如果请求被重定向,或者标注的属性被删除,该过滤器还是会被重复执行的。

DelegatingFilterProxy:将要实现的Filter功能注册到IOC容器的一个Bean,这样就可以和Spring IOC容器进行完美的融合。​DelegatingFilterProxy​​​是过滤器Bean的代理,配置​​DelegatingFilterProxy​​​时在Spring应用程序上下文中指定目标bean的名称​​targetBeanName​​,然后根据Bean名称从Spring 容器中获取到的代理Filter的实现类delegate,最终过滤操作调用的是委派的类delegate。

// 最终实现过滤的方法
protected void invokeDelegate(
Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 委派类执行
delegate.doFilter(request, response, filterChain);
}

3. Security的入口:FilterChainProxy

FilterChainProxy:在Security流程中,​​FilterChainProxy​​​作为​​DelegatingFilterProxy​​​的委派类被​​ApplicationFilterChain​​​执行。同样,主要的过滤逻辑在​​doFilterInternal​​方法中执行。

private void doFilterInternal(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {

// 拦截不安全的请求
FirewalledRequest fwRequest = firewall
.getFirewalledRequest((HttpServletRequest) request);
HttpServletResponse fwResponse = firewall
.getFirewalledResponse((HttpServletResponse) response);

// 从SecurityFilterChain列表第一个匹配的SecurityFilterChain中取得Filter链
// SecurityFilterChain列表中可能有多个匹配,但是这里只取得第一个匹配的,所以针对一个请求不能够配置多个SecurityFilterChain
List<Filter> filters = getFilters(fwRequest);

// 取得的过滤器为空,直接通过执行
if (filters == null || filters.size() == 0) {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(fwRequest)
+ (filters == null ? " has no matching filters"
: " has an empty filter list"));
}

fwRequest.reset();

chain.doFilter(fwRequest, fwResponse);

return;
}

//创建过滤器链
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
// 执行过滤,过滤逻辑类似于ApplicationFilterChain
vfc.doFilter(fwRequest, fwResponse);
}

​getFilters​​​步骤实现了根据​​Request​​​获取过滤器列表的操作,查看源码可以发现​​getFilters​​​方法实现的是遍历​​SecurityFilterChain​​​列表,获取第一个满足条件的chain,取得他的过滤器列表。而​​SecurityFilterChain​​​默认是针对所有url生效的,也就是说,第一个chain就会被匹配到,后面的chain永远不会生效,所以需要添加​​requestMatcher​​配置,限制生效范围。

private List<Filter> getFilters(HttpServletRequest request) {
for (SecurityFilterChain chain : filterChains) {
if (chain.matches(request)) {
return chain.getFilters();
}
}
return null;
}

4. Security多登陆入口配置


  1. 创建两个继承​​WebSecurityConfigurerAdapter​​​的配置类,其中管理员配置​​AdminWebSecurityConfiguration​​​加上​​.requestMatcher(new SkipPathAntMatcher("/admin/**"))​​​,限制其范围仅包括​​/admin/**​​的请求;
  2. 管理员配置加上了范围限制,用户配置还是全局范围,所以需要在用户​​UserWebSecurityConfiguration​​​中需要将管理员的​​/admin/**​​​加入白名单,否则会被用户部分登录拦截,建议使用​​@Order​​注解用来声明Security的执行顺序,值越小,优先级越高,越先被执行/初始化。
  3. 下方提供的配置稍有不同,我在用户配置中创建了一个​​.requestMatcher(new SkipPathAntMatcher("/admin/**"))​​​,他将匹配除​​/admin/**​​外的所有请求。

package com.nineya.user.config;

import com.nineya.user.filter.AdminLoginAuthorizationTokenFilter;
import com.nineya.user.filter.UserLoginAuthorizationTokenFilter;
import com.nineya.user.handler.LoginAuthenticationFailureHandler;
import com.nineya.user.handler.LoginAuthenticationSuccessHandler;
import com.nineya.user.handler.MessageAuthenticationEntryPoint;
import com.nineya.user.service.AdminDetailsServiceImpl;
import com.nineya.user.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.annotation.Resource;

/**
* 配置用户登录信息
* @author 殇雪话诀别
* 2020/11/22
*/
@EnableWebSecurity
public class WebSecurityConfiguration {
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private LoginAuthenticationSuccessHandler successHandler;
@Resource
private LoginAuthenticationFailureHandler failureHandler;
/**
* 请求网页未登录时抛出的异常信息
*/
@Resource
private MessageAuthenticationEntryPoint entryPoint;

@Configuration
@Order(1)
public class UserWebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsServiceImpl detailsService;
@Resource
private UserLoginAuthorizationTokenFilter tokenFilter;
private final String[] authWhitelist = new String[]{"/oauth/login", "/oauth/auth/login"};

// //认证管理器
// @Override
// @Bean
// public AuthenticationManager authenticationManagerBean() throws Exception {
// return super.authenticationManagerBean();
// }

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(detailsService)
.passwordEncoder(passwordEncoder);
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.requestMatcher(new SkipPathAntMatcher("/admin/**"))
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(n -> n
.loginPage("/oauth/login")
.loginProcessingUrl("/oauth/auth/login")
.successHandler(successHandler)
.failureHandler(failureHandler))
.logout(n -> n.logoutUrl("/oauth/logout"))
.exceptionHandling(n->n.authenticationEntryPoint(entryPoint))
.sessionManagement(n->n.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests(n -> n
.antMatchers(authWhitelist).permitAll()
.antMatchers("/user/api/**").authenticated()
.anyRequest().denyAll());
}

/**
* 用户的登录处理器
* @return 登录处理器
*/
public AuthenticationProvider userAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder);
provider.setUserDetailsService(detailsService);
return provider;
}
}

@Configuration
@Order(2)
public class AdminWebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Resource
private AdminDetailsServiceImpl detailsService;
@Resource
private AdminLoginAuthorizationTokenFilter tokenFilter;
private final String[] authWhitelist = new String[]{"/admin/login", "/admin/auth/login"};

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(detailsService)
.passwordEncoder(passwordEncoder);
}


@Override
protected void configure(HttpSecurity http) throws Exception {
http
.requestMatcher(new AntPathRequestMatcher("/admin/**"))
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(n -> n
.usernameParameter("mail")
.loginPage("/admin/login")
.loginProcessingUrl("/admin/auth/login")
.successHandler(successHandler)
.failureHandler(failureHandler))
.logout(n -> n.logoutUrl("/admin/auth/logout"))
.exceptionHandling(n->n.authenticationEntryPoint(entryPoint))
.sessionManagement(n->n.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests(n -> n
.antMatchers(authWhitelist).permitAll()
.antMatchers("/admin/api/**").authenticated()
.anyRequest().denyAll());
}

/**
* 管理员的登录处理器
* @return 登录处理器
*/
public AuthenticationProvider adminAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder);
provider.setUserDetailsService(detailsService);
return provider;
}
}

/**
* 注册密码加密器
*
* @return 密码加密器
*/
@Bean
public PasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}

在Security中并没有找到符合要求的请求匹配器,所以自己实现了一个​​SkipPathAntMatcher​​​匹配器,过滤除指定请求路径外的所有请求,其实现方式复制参考的​​AntPathRequestMatcher​​​,实现就是给​​AntPathRequestMatcher​​中的比较添加了个取反。

package com.nineya.user.config;

import java.util.Collections;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.http.HttpMethod;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestVariablesExtractor;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UrlPathHelper;


/**
* 排除选中的请求路径
*
* @author 殇雪话诀别
* 2020/11/28
*/
public final class SkipPathAntMatcher
implements RequestMatcher, RequestVariablesExtractor {
private static final Log logger = LogFactory.getLog(SkipPathAntMatcher.class);
private static final String MATCH_ALL = "/**";

private final Matcher matcher;
private final String pattern;
private final HttpMethod httpMethod;
private final boolean caseSensitive;

private final UrlPathHelper urlPathHelper;

/**
* 创建具有特定模式的匹配器,该模式将以区分大小写的方式排除所有HTTP路径。
*
* @param pattern 排除的路径
*/
public SkipPathAntMatcher(String pattern) {
this(pattern, null);
}

/**
* 以区分大小写的方式使用提供的模式和HTTP方法创建匹配器,只允许匹配的请求模式,不匹配的路径请求。
*
* @param pattern 排除的路径
* @param httpMethod 请求模式
*/
public SkipPathAntMatcher(String pattern, String httpMethod) {
this(pattern, httpMethod, true);
}

/**
* 以区分大小写的方式使用提供的模式和HTTP方法创建匹配器,指定路径是否判断大小写,只允许请求匹配的模式,不匹配的路径请求。
*
* @param pattern 排除的路径
* @param httpMethod 请求模式
* @param caseSensitive 是否考虑大小写
*/
public SkipPathAntMatcher(String pattern, String httpMethod,
boolean caseSensitive) {
this(pattern, httpMethod, caseSensitive, null);
}

/**
* 以区分大小写的方式使用提供的模式和HTTP方法创建匹配器,指定路径是否判断大小写,只允许请求匹配的模式,不匹配的路径请求。
*
* @param pattern 排除的路径
* @param httpMethod 请求模式
* @param caseSensitive 是否考虑大小写
* @param urlPathHelper 如果非空,将用于从HttpServletRequest提取路径
*/
public SkipPathAntMatcher(String pattern, String httpMethod,
boolean caseSensitive, UrlPathHelper urlPathHelper) {
Assert.hasText(pattern, "Pattern cannot be null or empty");
this.caseSensitive = caseSensitive;

if (pattern.equals(MATCH_ALL) || pattern.equals("**")) {
pattern = MATCH_ALL;
this.matcher = null;
} else {
// If the pattern ends with {@code /**} and has no other wildcards or path
// variables, then optimize to a sub-path match
if (pattern.endsWith(MATCH_ALL)
&& (pattern.indexOf('?') == -1 && pattern.indexOf('{') == -1
&& pattern.indexOf('}') == -1)
&& pattern.indexOf("*") == pattern.length() - 2) {
this.matcher = new SkipPathAntMatcher.SubpathMatcher(
pattern.substring(0, pattern.length() - 3), caseSensitive);
} else {
this.matcher = new SkipPathAntMatcher.SpringAntMatcher(pattern, caseSensitive);
}
}

this.pattern = pattern;
this.httpMethod = StringUtils.hasText(httpMethod) ? HttpMethod.valueOf(httpMethod)
: null;
this.urlPathHelper = urlPathHelper;
}

/**
* 如果配置的模式(和HTTP方法)与提供的请求的模式匹配,则返回false
*
* @param request 要匹配的请求。ant模式将与请求的{@code servletPath} + {@code pathInfo}进行匹配。
*/
@Override
public boolean matches(HttpServletRequest request) {
// 请求模式是否匹配
if (this.httpMethod != null && StringUtils.hasText(request.getMethod())
&& this.httpMethod != valueOf(request.getMethod())) {
if (logger.isDebugEnabled()) {
logger.debug("Request '" + request.getMethod() + " "
+ getRequestPath(request) + "'" + " doesn't match '"
+ this.httpMethod + " " + this.pattern + "'");
}

return false;
}
// 请求设置的是全局,全部拦截
if (this.pattern.equals(MATCH_ALL)) {
if (logger.isDebugEnabled()) {
logger.debug("Request '" + getRequestPath(request)
+ "' matched by universal pattern '/**'");
}

return false;
}

String url = getRequestPath(request);

if (logger.isDebugEnabled()) {
logger.debug("Checking match of request : '" + url + "'; against '"
+ this.pattern + "'");
}
System.out.println(url + " "+ !this.matcher.matches(url));
// 响应不匹配的链接
return !this.matcher.matches(url);
}

@Override
@Deprecated
public Map<String, String> extractUriTemplateVariables(HttpServletRequest request) {
return matcher(request).getVariables();
}

@Override
public RequestMatcher.MatchResult matcher(HttpServletRequest request) {
if (this.matcher == null || !matches(request)) {
return MatchResult.notMatch();
}
String url = getRequestPath(request);
return MatchResult.match(this.matcher.extractUriTemplateVariables(url));
}

private String getRequestPath(HttpServletRequest request) {
if (this.urlPathHelper != null) {
return this.urlPathHelper.getPathWithinApplication(request);
}
String url = request.getServletPath();

String pathInfo = request.getPathInfo();
if (pathInfo != null) {
url = StringUtils.hasLength(url) ? url + pathInfo : pathInfo;
}

return url;
}

public String getPattern() {
return this.pattern;
}

@Override
public boolean equals(Object obj) {
if (!(obj instanceof SkipPathAntMatcher)) {
return false;
}

SkipPathAntMatcher other = (SkipPathAntMatcher) obj;
return this.pattern.equals(other.pattern) && this.httpMethod == other.httpMethod
&& this.caseSensitive == other.caseSensitive;
}

@Override
public int hashCode() {
int result = this.pattern != null ? this.pattern.hashCode() : 0;
result = 31 * result + (this.httpMethod != null ? this.httpMethod.hashCode() : 0);
result = 31 * result + (this.caseSensitive ? 1231 : 1237);
return result;
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Ant [pattern='").append(this.pattern).append("'");

if (this.httpMethod != null) {
sb.append(", ").append(this.httpMethod);
}

sb.append("]");

return sb.toString();
}

/**
* Provides a save way of obtaining the HttpMethod from a String. If the method is
* invalid, returns null.
*
* @param method the HTTP method to use.
* @return the HttpMethod or null if method is invalid.
*/
private static HttpMethod valueOf(String method) {
try {
return HttpMethod.valueOf(method);
} catch (IllegalArgumentException e) {
}

return null;
}

private interface Matcher {
boolean matches(String path);

Map<String, String> extractUriTemplateVariables(String path);
}

private static class SpringAntMatcher implements SkipPathAntMatcher.Matcher {
private final AntPathMatcher antMatcher;

private final String pattern;

private SpringAntMatcher(String pattern, boolean caseSensitive) {
this.pattern = pattern;
this.antMatcher = createMatcher(caseSensitive);
}

@Override
public boolean matches(String path) {
return this.antMatcher.match(this.pattern, path);
}

@Override
public Map<String, String> extractUriTemplateVariables(String path) {
return this.antMatcher.extractUriTemplateVariables(this.pattern, path);
}

private static AntPathMatcher createMatcher(boolean caseSensitive) {
AntPathMatcher matcher = new AntPathMatcher();
matcher.setTrimTokens(false);
matcher.setCaseSensitive(caseSensitive);
return matcher;
}
}

/**
* 针对尾随通配符的优化匹配器
*/
private static class SubpathMatcher implements SkipPathAntMatcher.Matcher {
private final String subpath;
private final int length;
private final boolean caseSensitive;

private SubpathMatcher(String subpath, boolean caseSensitive) {
assert !subpath.contains("*");
this.subpath = caseSensitive ? subpath : subpath.toLowerCase();
this.length = subpath.length();
this.caseSensitive = caseSensitive;
}

@Override
public boolean matches(String path) {
if (!this.caseSensitive) {
path = path.toLowerCase();
}
return path.startsWith(this.subpath)
&& (path.length() == this.length || path.charAt(this.length) == '/');
}

@Override
public Map<String, String> extractUriTemplateVariables(String path) {
return Collections.emptyMap();
}
}
}