写在前面

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

想到的解决方法有两个:

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

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

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

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

  1. StandardWrapperValveinvoke方法,构造了一个ApplicationFilterChain对象,并执行了doFilter方法调用过滤器。ApplicationFilterChainjavax.servlet.FilterChain过滤器链的实现,用于管理针对特定请求的一组过滤器的执行,当定义的过滤器全部执行完成后,下一步将开始执行Servlet本身。

  2. doFilter方法判断了一下SecurityManager(Java安全管理器)是否开启,随后调用internalDoFilter方法,如果pos < n(过滤器还未全部遍历),则通过ApplicationFilterConfig获取或者创建过滤器实例,后调用过滤器的doFilter(ServletRequest request, ServletResponse response, FilterChain chain)方法,如果过滤器调用完成则开始执行Servletservlet.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();
        }
    }
}