Shrio使用Jwt達到前後端分離

  • 2019 年 10 月 3 日
  • 筆記

概述

前後端分離之後,因為HTTP本身是無狀態的,Session就沒法用了。項目採用jwt的方案後,請求的主要流程如下:用戶登錄成功之後,服務端會創建一個jwt的token(jwt的這個token中記錄了當前的操作帳號),並將這個token返回給前端,前端每次請求服務端的數據時,都會將令牌放入Header或者Parameter中,服務端接收到請求後,會先被攔截器攔截,token檢驗的攔截器會獲取請求中的token,然後會檢驗token的有效性,攔截器都檢驗成功後,請求會成功到達實際的業務流程中,執行業務邏輯返回給前端數據。在這個過程中,主要涉及到Shiro的攔截器鏈,Jwt的token管理,多Realm配置等。

Shiro的Filter鏈

Shiro的認證和授權都離不開Filter,因此需要對Shiro的Filter的運行流程很清楚,才能自定義Filter來滿足企業的實際需要。另外Shiro的Filter雖然原理都和Servlet的Filter相似,甚至都最終繼承相同的介面,但是實際還是有些差別。Shiro中的Filter主要是在ShiroFilter內,對指定匹配的URL進行攔截處理,它有自己的Filter鏈;而Servlet的Filter和ShiroFilter是同一個級別的,即先走Shiro自己的Filter體系,然後才會委託給Servlet容器的FilterChain進行Servlet容器級別的Filter鏈執行

分析Shiro的默認Filter

在Shiro和Spring Boot整合過程中,需要配置ShiroFilterFactoryBean,該類是ShiroFilter的工廠類,並繼承了FactoryBean介面。可以從該介面的方法來分析。該介面getObject獲取一個實例,按照邏輯,發現調用createFilterChainManager,並創建默認的Filter(按照命名猜測Map<String, Filter> defaultFilters = manager.getFilters())。

public class ShiroFilterFactoryBean implements FactoryBean, BeanPostProcessor {      private Map<String, Filter> filters;        private Map<String, String> filterChainDefinitionMap;        /**       *       * 該工廠類生產的產品類       */      public Object getObject() throws Exception {          if (instance == null) {              instance = createInstance();          }          return instance;      }        protected FilterChainManager createFilterChainManager() {          //創建默認Filter          DefaultFilterChainManager manager = new DefaultFilterChainManager();          Map<String, Filter> defaultFilters = manager.getFilters();          for (Filter filter : defaultFilters.values()) {              applyGlobalPropertiesIfNecessary(filter);          }            Map<String, Filter> filters = getFilters();          if (!CollectionUtils.isEmpty(filters)) {              for (Map.Entry<String, Filter> entry : filters.entrySet()) {                  String name = entry.getKey();                  Filter filter = entry.getValue();                  applyGlobalPropertiesIfNecessary(filter);                  if (filter instanceof Nameable) {                      ((Nameable) filter).setName(name);                  }                  manager.addFilter(name, filter, false);              }          }            Map<String, String> chains = getFilterChainDefinitionMap();          if (!CollectionUtils.isEmpty(chains)) {              for (Map.Entry<String, String> entry : chains.entrySet()) {                  String url = entry.getKey();                  String chainDefinition = entry.getValue();                  manager.createChain(url, chainDefinition);              }          }            return manager;      }        protected AbstractShiroFilter createInstance() throws Exception {            log.debug("Creating Shiro Filter instance.");            SecurityManager securityManager = getSecurityManager();          if (securityManager == null) {              String msg = "SecurityManager property must be set.";              throw new BeanInitializationException(msg);          }            if (!(securityManager instanceof WebSecurityManager)) {              String msg = "The security manager does not implement the WebSecurityManager interface.";              throw new BeanInitializationException(msg);          }          //創建FilterChainManager          FilterChainManager manager = createFilterChainManager();            PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();          chainResolver.setFilterChainManager(manager);            return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);      }       ...  }  

DefaultFilterChainManageraddDefaultFilters來添加默認的Filter,DefaultFilter為一系列默認Filter的枚舉類。

public class DefaultFilterChainManager implements FilterChainManager {        public Map<String, Filter> getFilters() {          return filters;      }        protected void addFilter(String name, Filter filter, boolean init, boolean overwrite) {          Filter existing = getFilter(name);          if (existing == null || overwrite) {              if (filter instanceof Nameable) {                  ((Nameable) filter).setName(name);              }              if (init) {                  initFilter(filter);              }              this.filters.put(name, filter);          }      }         /**       *       * 創建默認的Filter       */      protected void addDefaultFilters(boolean init) {          for (DefaultFilter defaultFilter : DefaultFilter.values()) {              addFilter(defaultFilter.name(), defaultFilter.newInstance(), init, false);          }      }      ...  }  

從這個枚舉類中可以看到之前添加的共有11個默認Filter,它們的名字分別是anon,authc,authcBaisc等。

public enum DefaultFilter {        anon(AnonymousFilter.class),      authc(FormAuthenticationFilter.class),      authcBasic(BasicHttpAuthenticationFilter.class),      logout(LogoutFilter.class),      noSessionCreation(NoSessionCreationFilter.class),      perms(PermissionsAuthorizationFilter.class),      port(PortFilter.class),      rest(HttpMethodPermissionFilter.class),      roles(RolesAuthorizationFilter.class),      ssl(SslFilter.class),      user(UserFilter.class);        private final Class<? extends Filter> filterClass;        private DefaultFilter(Class<? extends Filter> filterClass) {          this.filterClass = filterClass;      }        public Filter newInstance() {          return (Filter) ClassUtils.newInstance(this.filterClass);      }        public Class<? extends Filter> getFilterClass() {          return this.filterClass;      }      ...  }

Filter的繼承體系分析

  • NameableFilter給Filter起個名字,如果沒有設置,默認名字就是FilterName。

  • OncePerRequestFilter用於防止多次執行Filter;也就是說一次請求只會走一次攔截器鏈;另外提供 enabled 屬性,表示是否開啟該攔截器實例,默認 enabled=true 表示開啟,如果不想讓某個攔截器工作,可以設置為 false 即可。

  • AdviceFilter提供了AOP風格的支援。preHandler:在攔截器鏈執行之前執行,如果返回true則繼續攔截器鏈;否則中斷後續的攔截器鏈的執行直接返回;可以進行預處理(如身份驗證、授權等行為)。postHandle:在攔截器鏈執行完成後執行,後置處理(如記錄執行時間之類的)。afterCompletion:類似於AOP中的後置最終增強;即不管有沒有異常都會執行,可以進行清理資源(如接觸 Subject 與執行緒的綁定之類的)。

  • PathMatchingFilter內置了pathMatcher的實例,方便對請求路徑匹配功能及攔截器參數解析的功能,如下所示,對匹配的路徑執行isFilterChainContinued的邏輯,如果都沒配到,則直接交給攔截器鏈。

protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {        if (this.appliedPaths == null || this.appliedPaths.isEmpty()) {          if (log.isTraceEnabled()) {              log.trace("appliedPaths property is null or empty.  This Filter will passthrough immediately.");          }          return true;      }        for (String path : this.appliedPaths.keySet()) {          //對匹配路徑進行處理          if (pathsMatch(path, request)) {              log.trace("Current requestURI matches pattern '{}'.  Determining filter chain execution...", path);              Object config = this.appliedPaths.get(path);              return isFilterChainContinued(request, response, path, config);          }      }        return true;  }
  • AccessControlFilter提供了訪問控制的基礎功能,isAccessAllowed訪問通過,則交給攔截器鏈,不通過則執行onAccessDenied來確定交給攔截器還是自己處理
 public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {          return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, response, mappedValue);      }  
  • AuthenticationFilter認證Filter的基類,一般在isAccessAllowed中執行認證邏輯,另外該Filter提供登錄成功後跳轉的功能
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object      mappedValue) {      Subject subject = getSubject(request, response);      return subject.isAuthenticated();  }      protected void issueSuccessRedirect(ServletRequest request, ServletResponse response) throws        Exception {      WebUtils.redirectToSavedRequest(request, response, getSuccessUrl());  }
  • AuthenticatingFilter是AuthenticationFilter的子類,提供了executeLogin通用邏輯,通常由子類來實現protected abstract AuthenticationToken createToken(ServletRequest request, ServletResponse response)該方法,然後執行subject.login(token)
public abstract class AuthenticatingFilter extends AuthenticationFilter {      public static final String PERMISSIVE = "permissive";        protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {          AuthenticationToken token = createToken(request, response);          if (token == null) {              String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken " +                  "must be created in order to execute a login attempt.";              throw new IllegalStateException(msg);          }          try {              Subject subject = getSubject(request, response);              subject.login(token);              return onLoginSuccess(token, subject, request, response);          } catch (AuthenticationException e) {              return onLoginFailure(token, e, request, response);          }      }        protected abstract AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception;        protected AuthenticationToken createToken(String username, String password,                                                ServletRequest request, ServletResponse response) {          boolean rememberMe = isRememberMe(request);          String host = getHost(request);          return createToken(username, password, rememberMe, host);      }        protected AuthenticationToken createToken(String username, String password,                                                boolean rememberMe, String host) {          return new UsernamePasswordToken(username, password, rememberMe, host);      }        protected boolean onLoginSuccess(AuthenticationToken token, Subject subject,                                       ServletRequest request, ServletResponse response) throws Exception {          return true;      }        protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,                                       ServletRequest request, ServletResponse response) {          return false;      }        @Override      protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {          return super.isAccessAllowed(request, response, mappedValue) ||              (!isLoginRequest(request, response) && isPermissive(mappedValue));      }      ...  }

在Shiro中添加自定義的Filter

從上面源碼分析,知道了Shiro會提供11個默認的Filter,也是按照攔截器模式交由FilterChainManager來管理Filter,並最終返回SpringShiroFilter。所以添加自定義的Filter,主要有三步。

  • 實現自己的Filter

如下實現了自己的JwtFilter,主要邏輯可以參考FormAuthenticationFilter。JwtFilter主要是對前端的Api進行校驗,檢驗失敗,則拋出異常資訊,不給攔截器鏈處理。

@Slf4j  public class JwtFilter extends AuthenticatingFilter {      private static final String TOKEN_NAME = "token";        /**       * 創建令牌       */      @Override      protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {          final String token = getToken((HttpServletRequest) servletRequest);          if(StringUtils.isEmpty(token)) {              return null;          }          return new JwtToken(token);      }        /**       * 獲取令牌       * @param httpServletRequest       * @return       */      private String getToken(HttpServletRequest httpServletRequest) {          String token = httpServletRequest.getHeader(TOKEN_NAME);          if(StringUtils.isEmpty(token)) {              token = httpServletRequest.getParameter(TOKEN_NAME);          };          if(StringUtils.isEmpty(token)) {              Cookie[] cookies = httpServletRequest.getCookies();              if(ArrayUtils.isNotEmpty(cookies)) {                  for(Cookie cookie :cookies) {                      if(TOKEN_NAME.equals(cookie.getName())) {                          token = cookie.getValue();                          break;                      }                  }              }          };          return token;      }        /**       * 未通過處理       * @param servletRequest       * @param servletResponse       * @return       * @throws Exception       */      @Override      protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {          return executeLogin(servletRequest, servletResponse);      }        /**       * 登錄失敗執行方法       * @param token       * @param e       * @param request       * @param response       * @return       */      protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request,              ServletResponse response) {          response.setContentType("text/html;charset=UTF-8");          try(OutputStream outputStream = response.getOutputStream()){              outputStream.write(e.getMessage().getBytes(SystemConsts.CHARSET));              outputStream.flush();          } catch (IOException e1) {              e1.printStackTrace();          }          return false;      }      ...  }
  • 將Filter添加到Shiro中

將自定義的Filter添加到Shiro,並要指定的匹配路徑。

public ShiroFilterFactoryBean getShiroFilterFactoryBean(@Autowired          org.apache.shiro.mgt.SecurityManager securityManager, @Autowired JwtFilter jwtFilter) {          ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();            Map<String, Filter> filterMap = new LinkedHashMap<>();          filterMap.put("jwt", jwtFilter);          shiroFilterFactoryBean.setFilters(filterMap);            Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();          filterChainDefinitionMap.put("/**", "jwt");          shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);      ...          return shiroFilterFactoryBean;      }

注意:SpringBoot自動幫我們註冊了我們的Filter(Filter是註冊到整個Filter鏈,而不是Shiro的Filter鏈),但是在Shiro中,我們需要自己實現註冊,但是又需要Filter實例存在於Spring容器中,以便能使用其他眾多服務(自動注入其他組件……)。所以需要取消Spring Boot的自動注入Filter。可以採用如下方式:

@Bean  public FilterRegistrationBean registration(@Qualifier("devCryptoFilter") DevCryptoFilter filter){      FilterRegistrationBean registration = new FilterRegistrationBean(filter);      registration.setEnabled(false);      return registration;  }

Jwt整合

使用Jwt需要我們提供對token的創建,校驗和獲取token中資訊的方法。網上有很多,可以借鑒,而且token中也可以存一些其他數據。

public class JwtUtil {        /**       * 檢驗token       * @return boolean       */      public static boolean verify(String token, String username) {          ...      }        /**       * 獲得token中的屬性       * @return token中包含的屬性       */      public static String getValue(String token, String key) {          ...      }        /**       * 生成token簽名EXPIRE_TIME 分鐘後過期       *       * @param username       *            用戶名       * @return 加密的token       */      public static String createJWT(String userId) {          ...      }  }

多Realm配置

用戶密碼認證和Jwt的認證需要不同的兩個Realm,多Realm需要處理不同的Realm,獲取到指定Realm的AuthenticationToken的數據模型。

  • 實現ModularRealmAuthenticator的方法
public class MultiRealmAuthenticator extends ModularRealmAuthenticator {        @Override      protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken)              throws AuthenticationException {          assertRealmsConfigured();            List<Realm> realms = this.getRealms()                  .stream()                  .filter(realm -> {                      return realm.supports(authenticationToken);                  })                  .collect(Collectors.toList());            return realms.size() == 1 ? this.doSingleRealmAuthentication(realms.get(0), authenticationToken) :              this.doMultiRealmAuthentication(realms, authenticationToken);      }  }
  • AuthenticatingRealm中實現getAuthenticationTokenClass方法
public Class getAuthenticationTokenClass() {      return JwtToken.class;  }
  • 在SecurityManager中配置
@Bean(name = "securityManager")  public org.apache.shiro.mgt.SecurityManager defaultWebSecurityManager(@Autowired UserRealm      userRealm,  @Autowired TokenRealm tokenValidateRealm) {      securityManager.setAuthenticator(multiRealmAuthenticator());      securityManager.setRealms(Arrays.asList(userRealm, tokenValidateRealm));      ...      return securityManager;  }

整合Swagger

添加Swagger依賴

<dependency>      <groupId>io.springfox</groupId>      <artifactId>springfox-swagger2</artifactId>      <version>2.9.2</version>  </dependency>    <dependency>      <groupId>io.springfox</groupId>      <artifactId>springfox-swagger-ui</artifactId>      <version>2.9.2</version>  </dependency>

添加Swagger的配置

@Configuration  public class Swagger2Config {        @Bean      public Docket createRestApi() {          return new Docket(DocumentationType.SWAGGER_2)                  .apiInfo(apiInfo())                  .select()                  .apis(RequestHandlerSelectors.basePackage("XXX"))                  .paths(PathSelectors.any())                  .build();      }        private ApiInfo apiInfo() {          return new ApiInfoBuilder()                  .title("XXX")                  .description("經供參考")                  .version("1.0")                  .build();      }  }

總結

在整個過程中,遇到的坑就是在Spring boot中Filter的自動注入,中間考慮有不使用注入的方式解決,即直接使用new JwtFilter()的方式,雖然也能解決問題,但是不是很完美,最終還是在網上找到解決方案。對Shiro的Filter鏈的執行過程加強了理解,能夠使用自定的Filter解決實際問題。還有一個後續的問題,退出登錄時的Jwt的token處理,它本身不能像Session一樣,退出就清除,理論上只要沒過期,就一直存在。可以考慮使用快取,退出時清除即可,然後在校驗時,先從快取獲取進行判斷。