Spring Security做JWT認(rèn)證和授權(quán)

上一篇博客講了如何使用Shiro和JWT做認(rèn)證和授權(quán)(傳送門:http://m.itdecent.cn/p/0b1131be7ace),總的來說shiro是一個比較早期和簡單的框架,這個從最近已經(jīng)基本不做版本更新就可以看出來。這篇文章我們講一下如何使用更加流行和完整的spring security來實現(xiàn)同樣的需求。

Spring Security的架構(gòu)

按照慣例,在使用之前我們先講一下簡單的架構(gòu)。不知道是因為spring-security后出來還是因為優(yōu)秀的設(shè)計殊途同歸,對于核心模塊,spring-security和shiro有80%以上的設(shè)計相似度。所以下面介紹中會多跟shiro做對比,如果你對shiro不了解也沒關(guān)系,跟shiro對比的部分跳過就好。

spring-security中核心概念

  • AuthenticationManager, 用戶認(rèn)證的管理類,所有的認(rèn)證請求(比如login)都會通過提交一個token給AuthenticationManagerauthenticate()方法來實現(xiàn)。當(dāng)然事情肯定不是它來做,具體校驗動作會由AuthenticationManager將請求轉(zhuǎn)發(fā)給具體的實現(xiàn)類來做。根據(jù)實現(xiàn)反饋的結(jié)果再調(diào)用具體的Handler來給用戶以反饋。這個類基本等同于shiro的SecurityManager。
  • AuthenticationProvider, 認(rèn)證的具體實現(xiàn)類,一個provider是一種認(rèn)證方式的實現(xiàn),比如提交的用戶名密碼我是通過和DB中查出的user記錄做比對實現(xiàn)的,那就有一個DaoProvider;如果我是通過CAS請求單點(diǎn)登錄系統(tǒng)實現(xiàn),那就有一個CASProvider。這個是不是和shiro的Realm的定義很像?基本上你可以幫他們當(dāng)成同一個東西。按照Spring一貫的作風(fēng),主流的認(rèn)證方式它都已經(jīng)提供了默認(rèn)實現(xiàn),比如DAO、LDAP、CAS、OAuth2等。
    前面講了AuthenticationManager只是一個代理接口,真正的認(rèn)證就是由AuthenticationProvider來做的。一個AuthenticationManager可以包含多個Provider,每個provider通過實現(xiàn)一個support方法來表示自己支持那種Token的認(rèn)證。AuthenticationManager默認(rèn)的實現(xiàn)類是ProviderManager。
  • UserDetailService, 用戶認(rèn)證通過Provider來做,所以Provider需要拿到系統(tǒng)已經(jīng)保存的認(rèn)證信息,獲取用戶信息的接口spring-security抽象成UserDetailService。雖然叫Service,但是我更愿意把它認(rèn)為是我們系統(tǒng)里經(jīng)常有的UserDao。
  • AuthenticationToken, 所有提交給AuthenticationManager的認(rèn)證請求都會被封裝成一個Token的實現(xiàn),比如最容易理解的UsernamePasswordAuthenticationToken。這個就不多講了,連名字都跟Shiro中一樣。
  • SecurityContext,當(dāng)用戶通過認(rèn)證之后,就會為這個用戶生成一個唯一的SecurityContext,里面包含用戶的認(rèn)證信息Authentication。通過SecurityContext我們可以獲取到用戶的標(biāo)識Principle和授權(quán)信息GrantedAuthrity。在系統(tǒng)的任何地方只要通過SecurityHolder.getSecruityContext()就可以獲取到SecurityContext。在Shiro中通過SecurityUtils.getSubject()到達(dá)同樣的目的。
    我們大概通過一個認(rèn)證流程來認(rèn)識下上面幾個關(guān)鍵的概念
    認(rèn)證流程

對web系統(tǒng)的支持

毫無疑問,對于spring框架使用最多的還是web系統(tǒng)。對于web系統(tǒng)來說進(jìn)入認(rèn)證的最佳入口就是Filter了。spring security不僅實現(xiàn)了認(rèn)證的邏輯,還通過filter實現(xiàn)了常見的web攻擊的防護(hù)。
常用Filter
下面按照request進(jìn)入的順序列舉一下常用的Filter:

  • SecurityContextPersistenceFilter,用于將SecurityContext放入Session的Filter
  • UsernamePasswordAuthenticationFilter, 登錄認(rèn)證的Filter,類似的還有CasAuthenticationFilter,BasicAuthenticationFilter等等。在這些Filter中生成用于認(rèn)證的token,提交到AuthenticationManager,如果認(rèn)證失敗會直接返回。
  • RememberMeAuthenticationFilter,通過cookie來實現(xiàn)remember me功能的Filter
  • AnonymousAuthenticationFilter,如果一個請求在到達(dá)這個filter之前SecurityContext沒有初始化,則這個filter會默認(rèn)生成一個匿名SecurityContext。這在支持匿名用戶的系統(tǒng)中非常有用。
  • ExceptionTranslationFilter,捕獲所有Spring Security拋出的異常,并決定處理方式
  • FilterSecurityInterceptor, 權(quán)限校驗的攔截器,訪問的url權(quán)限不足時會拋出異常
    Filter的順序
    既然用了上面那么多filter,它們在FilterChain中的先后順序就顯得非常重要了。對于每一個系統(tǒng)或者用戶自定義的filter,spring security都要求必須指定一個order,用來做排序。對于系統(tǒng)的filter的默認(rèn)順序,是在一個FilterComparator類中定義的,核心實現(xiàn)如下。
    FilterComparator() {
        int order = 100;
        put(ChannelProcessingFilter.class, order);
        order += STEP;
        put(ConcurrentSessionFilter.class, order);
        order += STEP;
        put(WebAsyncManagerIntegrationFilter.class, order);
        order += STEP;
        put(SecurityContextPersistenceFilter.class, order);
        order += STEP;
        put(HeaderWriterFilter.class, order);
        order += STEP;
        put(CorsFilter.class, order);
        order += STEP;
        put(CsrfFilter.class, order);
        order += STEP;
        put(LogoutFilter.class, order);
        order += STEP;
        filterToOrder.put(
            "org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter",
            order);
        order += STEP;
        put(X509AuthenticationFilter.class, order);
        order += STEP;
        put(AbstractPreAuthenticatedProcessingFilter.class, order);
        order += STEP;
        filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter",
                order);
        order += STEP;
        filterToOrder.put(
            "org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter",
            order);
        order += STEP;
        put(UsernamePasswordAuthenticationFilter.class, order);
        order += STEP;
        put(ConcurrentSessionFilter.class, order);
        order += STEP;
        filterToOrder.put(
                "org.springframework.security.openid.OpenIDAuthenticationFilter", order);
        order += STEP;
        put(DefaultLoginPageGeneratingFilter.class, order);
        order += STEP;
        put(ConcurrentSessionFilter.class, order);
        order += STEP;
        put(DigestAuthenticationFilter.class, order);
        order += STEP;
        put(BasicAuthenticationFilter.class, order);
        order += STEP;
        put(RequestCacheAwareFilter.class, order);
        order += STEP;
        put(SecurityContextHolderAwareRequestFilter.class, order);
        order += STEP;
        put(JaasApiIntegrationFilter.class, order);
        order += STEP;
        put(RememberMeAuthenticationFilter.class, order);
        order += STEP;
        put(AnonymousAuthenticationFilter.class, order);
        order += STEP;
        put(SessionManagementFilter.class, order);
        order += STEP;
        put(ExceptionTranslationFilter.class, order);
        order += STEP;
        put(FilterSecurityInterceptor.class, order);
        order += STEP;
        put(SwitchUserFilter.class, order);
    }

對于用戶自定義的filter,如果要加入spring security 的FilterChain中,必須指定加到已有的那個filter之前或者之后,具體下面我們用到自定義filter的時候會說明。

JWT認(rèn)證的實現(xiàn)

關(guān)于使用JWT認(rèn)證的原因,上一篇介紹Shiro的文章中已經(jīng)說過了,這里不再多說。需求也還是那3個:

  • 支持用戶通過用戶名和密碼登錄
  • 登錄后通過http header返回token,每次請求,客戶端需通過header將token帶回,用于權(quán)限校驗
  • 服務(wù)端負(fù)責(zé)token的定期刷新
    下面我們直接進(jìn)入Spring Secuiry的項目搭建。

項目搭建

gradle配置

最新的spring項目開始默認(rèn)使用gradle來做依賴管理了,所以這個項目也嘗試下gradle的配置。除了springmvc和security的starter之外,還依賴了auth0的jwt工具包。JSON處理使用了fastjson。

buildscript {
    ext {
        springBootVersion = '2.0.4.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'com.github.springboot'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter-security')    
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.apache.commons:commons-lang3:3.8')
    compile('com.auth0:java-jwt:3.4.0')
    compile('com.alibaba:fastjson:1.2.47')
    
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
}

登錄認(rèn)證流程

Filter
對于用戶登錄行為,security通過定義一個Filter來攔截/login來實現(xiàn)的。spring security默認(rèn)支持form方式登錄,所以對于使用json發(fā)送登錄信息的情況,我們自己定義一個Filter,這個Filter直接從AbstractAuthenticationProcessingFilter繼承,只需要實現(xiàn)兩部分,一個是RequestMatcher,指名攔截的Request類型;另外就是從json body中提取出username和password提交給AuthenticationManager。

public class MyUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
    public MyUsernamePasswordAuthenticationFilter() {
         //攔截url為 "/login" 的POST請求
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        //從json中獲取username和password
        String body = StreamUtils.copyToString(request.getInputStream(), Charset.forName("UTF-8"));
        String username = null, password = null;
        if(StringUtils.hasText(body)) {
            JSONObject jsonObj = JSON.parseObject(body);
            username = jsonObj.getString("username");
            password = jsonObj.getString("password");
        }   
        
        if (username == null) 
            username = "";
        if (password == null)
            password = "";
        username = username.trim();
       //封裝到token中提交
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);
        
        return this.getAuthenticationManager().authenticate(authRequest);
    }

}

Provider
前面的流程圖中講到了,封裝后的token最終是交給provider來處理的。對于登錄的provider,spring security已經(jīng)提供了一個默認(rèn)實現(xiàn)DaoAuthenticationProvider我們可以直接使用,這個類繼承了AbstractUserDetailsAuthenticationProvider我們來看下關(guān)鍵部分的源代碼是怎么做的。

public abstract class AbstractUserDetailsAuthenticationProvider implements
        AuthenticationProvider, InitializingBean, MessageSourceAware {
...
    //這個方法返回true,說明支持該類型的token
    public boolean supports(Class<?> authentication) {
        return (UsernamePasswordAuthenticationToken.class
                .isAssignableFrom(authentication));
   }
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
             ...

            try {
            // 獲取系統(tǒng)中存儲的用戶信息
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException notFound) {
                logger.debug("User '" + username + "' not found");

                if (hideUserNotFoundExceptions) {
                    throw new BadCredentialsException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.badCredentials",
                            "Bad credentials"));
                }
                else {
                    throw notFound;
                }
            }

            
        }

        try {
           //檢查user是否已過期或者已鎖定
            preAuthenticationChecks.check(user);
           //將獲取到的用戶信息和登錄信息做比對
            additionalAuthenticationChecks(user,
                    (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException exception) {
            ...
            throw exception;            
        }
        ...
        //如果認(rèn)證通過,則封裝一個AuthenticationInfo, 放到SecurityContext中
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

...
}

上面的代碼中,核心流程就是retrieveUser()獲取系統(tǒng)中存儲的用戶信息,再對用戶信息做了過期和鎖定等校驗后交給additionalAuthenticationChecks()和用戶提交的信息做比對。
這兩個方法我們看他的繼承類DaoAuthenticationProvider是怎么實現(xiàn)的。

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    /**
     * 加密密碼比對
     */
     protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        if (authentication.getCredentials() == null) {
            logger.debug("Authentication failed: no credentials provided");

            throw new BadCredentialsException(messages.getMessage(
                    "AbstractUserDetailsAuthenticationProvider.badCredentials",
                    "Bad credentials"));
        }

        String presentedPassword = authentication.getCredentials().toString();

        if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            logger.debug("Authentication failed: password does not match stored value");

            throw new BadCredentialsException(messages.getMessage(
                    "AbstractUserDetailsAuthenticationProvider.badCredentials",
                    "Bad credentials"));
        }
    }
   /**
    * 系統(tǒng)用戶獲取
    */
    protected final UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        prepareTimingAttackProtection();
        try {
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException(
                        "UserDetailsService returned null, which is an interface contract violation");
            }
            return loadedUser;
        }
        catch (UsernameNotFoundException ex) {
            mitigateAgainstTimingAttack(authentication);
            throw ex;
        }
        catch (InternalAuthenticationServiceException ex) {
            throw ex;
        }
        catch (Exception ex) {
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
        }
    }
}

上面的方法實現(xiàn)中,用戶獲取是調(diào)用了UserDetailsService來完成的。這個是一個只有一個方法的接口,所以我們自己要做的,就是將自己的UserDetailsService實現(xiàn)類配置成一個Bean。下面是實例代碼,真正的實現(xiàn)需要從數(shù)據(jù)庫或者緩存中獲取。

public class JwtUserService implements UserDetailsService{
    //真實系統(tǒng)需要從數(shù)據(jù)庫或緩存中獲取,這里對密碼做了加密
     return User.builder().username("Jack").password(passwordEncoder.encode("jack-password")).roles("USER").build();
}

我們再來看另外一個密碼比對的方法,也是委托給一個PasswordEncoder類來實現(xiàn)的。一般來說,存在數(shù)據(jù)庫中的密碼都是要經(jīng)過加密處理的,這樣萬一數(shù)據(jù)庫數(shù)據(jù)被拖走,也不會泄露密碼。spring一如既往的提供了主流的加密方式,如MD5,SHA等。如果不顯示指定的話,Spring會默認(rèn)使用BCryptPasswordEncoder,這個是目前相對比較安全的加密方式。具體介紹可參考spring-security 的官方文檔 - Password Endcoding
認(rèn)證結(jié)果處理
filter將token交給provider做校驗,校驗的結(jié)果無非兩種,成功或者失敗。對于這兩種結(jié)果,我們只需要實現(xiàn)兩個Handler接口,set到Filter里面,F(xiàn)ilter在收到Provider的處理結(jié)果后會回調(diào)這兩個Handler的方法。
先來看成功的情況,針對jwt認(rèn)證的業(yè)務(wù)場景,登錄成功需要返回給客戶端一個token。所以成功的handler的實現(xiàn)類中需要包含這個邏輯。

public class JsonLoginSuccessHandler implements AuthenticationSuccessHandler{
    
    private JwtUserService jwtUserService;
    
    public JsonLoginSuccessHandler(JwtUserService jwtUserService) {
        this.jwtUserService = jwtUserService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
                //生成token,并把token加密相關(guān)信息緩存,具體請看實現(xiàn)類
        String token = jwtUserService.saveUserLoginInfo((UserDetails)authentication.getPrincipal());
        response.setHeader("Authorization", token);
    }
    
}

再來看失敗的情況,登錄失敗比較簡單,只需要回復(fù)一個401的Response即可。

public class HttpStatusLoginFailureHandler implements AuthenticationFailureHandler{
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());    
    }   
}

JsonLoginConfigurer
以上整個登錄的流程的組件就完整了,我們只需要把它們組合到一起就可以了。這里繼承一個AbstractHttpConfigurer,對Filter做配置。

public class JsonLoginConfigurer<T extends JsonLoginConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B>  {

    private MyUsernamePasswordAuthenticationFilter authFilter;

    public JsonLoginConfigurer() {
        this.authFilter = new MyUsernamePasswordAuthenticationFilter();
    }
    
    @Override
    public void configure(B http) throws Exception {
        //設(shè)置Filter使用的AuthenticationManager,這里取公共的即可
        authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        //設(shè)置失敗的Handler
        authFilter.setAuthenticationFailureHandler(new HttpStatusLoginFailureHandler());
        //不將認(rèn)證后的context放入session
        authFilter.setSessionAuthenticationStrategy(new NullAuthenticatedSessionStrategy());

        MyUsernamePasswordAuthenticationFilter filter = postProcess(authFilter);
        //指定Filter的位置
        http.addFilterAfter(filter, LogoutFilter.class);
    }
    //設(shè)置成功的Handler,這個handler定義成Bean,所以從外面set進(jìn)來
    public JsonLoginConfigurer<T,B> loginSuccessHandler(AuthenticationSuccessHandler authSuccessHandler){
        authFilter.setAuthenticationSuccessHandler(authSuccessHandler);
        return this;
    }

}

這樣Filter就完整的配置好了,當(dāng)調(diào)用configure方法時,這個filter就會加入security FilterChain的指定位置。這個是在全局定義的地方,我們放在最后說。在全局配置的地方,也會將DaoAuthenticationProvider放到ProviderManager中,這樣filter中提交的token就可以被處理了。

帶Token請求校驗流程

用戶除登錄之外的請求,都要求必須攜帶JWT Token。所以我們需要另外一個Filter對這些請求做一個攔截。這個攔截器主要是提取header中的token,跟登錄一樣,提交給AuthenticationManager做檢查。
Filter

public class JwtAuthenticationFilter extends OncePerRequestFilter{
    ...
    public JwtAuthenticationFilter() {
        //攔截header中帶Authorization的請求
        this.requiresAuthenticationRequestMatcher = new RequestHeaderRequestMatcher("Authorization");
    }
    
    protected String getJwtToken(HttpServletRequest request) {
        String authInfo = request.getHeader("Authorization");
        return StringUtils.removeStart(authInfo, "Bearer ");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
       //header沒帶token的,直接放過,因為部分url匿名用戶也可以訪問
       //如果需要不支持匿名用戶的請求沒帶token,這里放過也沒問題,因為SecurityContext中沒有認(rèn)證信息,后面會被權(quán)限控制模塊攔截
        if (!requiresAuthentication(request, response)) {
            filterChain.doFilter(request, response);
            return;
        }
        Authentication authResult = null;
        AuthenticationException failed = null;
        try {
            //從頭中獲取token并封裝后提交給AuthenticationManager
            String token = getJwtToken(request);
            if(StringUtils.isNotBlank(token)) {
                JwtAuthenticationToken authToken = new JwtAuthenticationToken(JWT.decode(token));               
                authResult = this.getAuthenticationManager().authenticate(authToken);
            } else {  //如果token長度為0
                failed = new InsufficientAuthenticationException("JWT is Empty");
            }
        } catch(JWTDecodeException e) {
            logger.error("JWT format error", e);
            failed = new InsufficientAuthenticationException("JWT format error", failed);
        }catch (InternalAuthenticationServiceException e) {
            logger.error(
                    "An internal error occurred while trying to authenticate the user.",
                    failed);
            failed = e;
        }catch (AuthenticationException e) {
            // Authentication failed            
            failed = e;
        }
        if(authResult != null) {   //token認(rèn)證成功
            successfulAuthentication(request, response, filterChain, authResult);
        } else if(!permissiveRequest(request)){   
            //token認(rèn)證失敗,并且這個request不在例外列表里,才會返回錯誤
            unsuccessfulAuthentication(request, response, failed);
            return;
        }
        filterChain.doFilter(request, response);
    }
    
    ...

    protected boolean requiresAuthentication(HttpServletRequest request,
            HttpServletResponse response) {
        return requiresAuthenticationRequestMatcher.matches(request);
    }
    
    protected boolean permissiveRequest(HttpServletRequest request) {
        if(permissiveRequestMatchers == null)
            return false;
        for(RequestMatcher permissiveMatcher : permissiveRequestMatchers) {
            if(permissiveMatcher.matches(request))
                return true;
        }       
        return false;
    }
}

這個Filter的實現(xiàn)跟登錄的Filter有幾點(diǎn)區(qū)別:

  • 經(jīng)過這個Filter的請求,會繼續(xù)過FilterChain中的其它Filter。因為跟登錄請求不一樣,token只是為了識別用戶。
  • 如果header中沒有認(rèn)證信息或者認(rèn)證失敗,還會判斷請求的url是否強(qiáng)制認(rèn)證的(通過permissiveRequest方法判斷)。如果請求不是強(qiáng)制認(rèn)證,也會放過,這種情況比如博客類應(yīng)用匿名用戶訪問查看頁面;比如登出操作,如果未登錄用戶點(diǎn)擊登出,我們一般是不會報錯的。
    其它邏輯跟登錄一樣,組裝一個token提交給AuthenticationManager。

JwtAuthenticationProvider
同樣我們需要一個provider來接收jwt的token,在收到token請求后,會從數(shù)據(jù)庫或者緩存中取出salt,對token做驗證,代碼如下:

public class JwtAuthenticationProvider implements AuthenticationProvider{
    
    private JwtUserService userService;
    
    public JwtAuthenticationProvider(JwtUserService userService) {
        this.userService = userService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        DecodedJWT jwt = ((JwtAuthenticationToken)authentication).getToken();
        if(jwt.getExpiresAt().before(Calendar.getInstance().getTime()))
            throw new NonceExpiredException("Token expires");
        String username = jwt.getSubject();
        UserDetails user = userService.getUserLoginInfo(username);
        if(user == null || user.getPassword()==null)
            throw new NonceExpiredException("Token expires");
        String encryptSalt = user.getPassword();
        try {
            Algorithm algorithm = Algorithm.HMAC256(encryptSalt);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withSubject(username)
                    .build();
            verifier.verify(jwt.getToken());
        } catch (Exception e) {
            throw new BadCredentialsException("JWT token verify fail", e);
        }
        //成功后返回認(rèn)證信息,filter會將認(rèn)證信息放入SecurityContext
        JwtAuthenticationToken token = new JwtAuthenticationToken(user, jwt, user.getAuthorities());
        return token;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.isAssignableFrom(JwtAuthenticationToken.class);
    }

}

認(rèn)證結(jié)果Handler
如果token認(rèn)證失敗,并且不在permissive列表中話,就會調(diào)用FailHandler,這個Handler和登錄行為一致,所以都使用HttpStatusLoginFailureHandler 返回401錯誤。
token認(rèn)證成功,在繼續(xù)FilterChain中的其它Filter之前,我們先檢查一下token是否需要刷新,刷新成功后會將新token放入header中。所以,新增一個JwtRefreshSuccessHandler來處理token認(rèn)證成功的情況。

public class JwtRefreshSuccessHandler implements AuthenticationSuccessHandler{
    
    private static final int tokenRefreshInterval = 300;  //刷新間隔5分鐘
    
    private JwtUserService jwtUserService;
    
    public JwtRefreshSuccessHandler(JwtUserService jwtUserService) {
        this.jwtUserService = jwtUserService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        DecodedJWT jwt = ((JwtAuthenticationToken)authentication).getToken();
        boolean shouldRefresh = shouldTokenRefresh(jwt.getIssuedAt());
        if(shouldRefresh) {
            String newToken = jwtUserService.saveUserLoginInfo((UserDetails)authentication.getPrincipal());
            response.setHeader("Authorization", newToken);
        }   
    }
    
    protected boolean shouldTokenRefresh(Date issueAt){
        LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault());
        return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueTime);
    }

}

JwtLoginConfigurer
跟登錄邏輯一樣,我們定義一個configurer,用來初始化和配置JWTFilter。

public class JwtLoginConfigurer<T extends JwtLoginConfigurer<T, B>, B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<T, B> {
    
    private JwtAuthenticationFilter authFilter;
    
    public JwtLoginConfigurer() {
        this.authFilter = new JwtAuthenticationFilter();
    }
    
    @Override
    public void configure(B http) throws Exception {
        authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        authFilter.setAuthenticationFailureHandler(new HttpStatusLoginFailureHandler());
        //將filter放到logoutFilter之前
        JwtAuthenticationFilter filter = postProcess(authFilter);
        http.addFilterBefore(filter, LogoutFilter.class);
    }
    //設(shè)置匿名用戶可訪問url
    public JwtLoginConfigurer<T, B> permissiveRequestUrls(String ... urls){
        authFilter.setPermissiveUrl(urls);
        return this;
    }
    
    public JwtLoginConfigurer<T, B> tokenValidSuccessHandler(AuthenticationSuccessHandler successHandler){
        authFilter.setAuthenticationSuccessHandler(successHandler);
        return this;
    }
    
}

配置集成

整個登錄和無狀態(tài)用戶認(rèn)證的流程都已經(jīng)講完了,現(xiàn)在我們需要吧spring security集成到我們的web項目中去。spring security和spring mvc做了很好的集成,一共只需要做兩件事,給web配置類加上@EanbleWebSecurity,繼承WebSecurityConfigurerAdapter定義個性化配置。
配置類WebSecurityConfig

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{

    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/image/**").permitAll() //靜態(tài)資源訪問無需認(rèn)證
                .antMatchers("/admin/**").hasAnyRole("ADMIN") //admin開頭的請求,需要admin權(quán)限
                .antMatchers("/article/**").hasRole("USER") //需登陸才能訪問的url
                .anyRequest().authenticated()  //默認(rèn)其它的請求都需要認(rèn)證,這里一定要添加
                .and()
            .csrf().disable()  //CRSF禁用,因為不使用session
            .sessionManagement().disable()  //禁用session
            .formLogin().disable() //禁用form登錄
            .cors()  //支持跨域
            .and()   //添加header設(shè)置,支持跨域和ajax請求
            .headers().addHeaderWriter(new StaticHeadersWriter(Arrays.asList(
                    new Header("Access-control-Allow-Origin","*"),
                    new Header("Access-Control-Expose-Headers","Authorization"))))
            .and() //攔截OPTIONS請求,直接返回header
            .addFilterAfter(new OptionRequestFilter(), CorsFilter.class)
            //添加登錄filter
            .apply(new JsonLoginConfigurer<>()).loginSuccessHandler(jsonLoginSuccessHandler())
            .and()
           //添加token的filter
            .apply(new JwtLoginConfigurer<>()).tokenValidSuccessHandler(jwtRefreshSuccessHandler()).permissiveRequestUrls("/logout")
            .and()
            //使用默認(rèn)的logoutFilter
            .logout()
//              .logoutUrl("/logout")   //默認(rèn)就是"/logout"
                .addLogoutHandler(tokenClearLogoutHandler())  //logout時清除token
                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()) //logout成功后返回200
            .and()
            .sessionManagement().disable();
    }
    //配置provider
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider()).authenticationProvider(jwtAuthenticationProvider());
    }
    
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    
    @Bean("jwtAuthenticationProvider")
    protected AuthenticationProvider jwtAuthenticationProvider() {
        return new JwtAuthenticationProvider(jwtUserService());
    }
    
    @Bean("daoAuthenticationProvider")
    protected AuthenticationProvider daoAuthenticationProvider() throws Exception{
        //這里會默認(rèn)使用BCryptPasswordEncoder比對加密后的密碼,注意要跟createUser時保持一致
        DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
        daoProvider.setUserDetailsService(userDetailsService());
        return daoProvider;
    }
    ...
}

以上的配置類主要關(guān)注一下幾個點(diǎn):

  • 訪問權(quán)限配置,使用url匹配是放過還是需要角色和認(rèn)證
  • 跨域支持,這個我們下面再講
  • 禁用csrf,csrf攻擊是針對使用session的情況,這里是不需要的,關(guān)于CSRF可參考 Cross Site Request Forgery
  • 禁用默認(rèn)的form登錄支持
  • logout支持,spring security已經(jīng)默認(rèn)支持logout filter,會攔截/logout請求,交給logoutHandler處理,同時在logout成功后調(diào)用LogoutSuccessHandler。對于logout,我們需要清除保存的token salt信息,這樣再拿logout之前的token訪問就會失敗。請參考TokenClearLogoutHandler:
public class TokenClearLogoutHandler implements LogoutHandler {
    
    private JwtUserService jwtUserService;
    
    public TokenClearLogoutHandler(JwtUserService jwtUserService) {
        this.jwtUserService = jwtUserService;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        clearToken(authentication);
    }
    
    protected void clearToken(Authentication authentication) {
        if(authentication == null)
            return;
        UserDetails user = (UserDetails)authentication.getPrincipal();
        if(user!=null && user.getUsername()!=null)
            jwtUserService.deleteUserLoginInfo(user.getUsername());
    }

}

角色配置

Spring Security對于訪問權(quán)限的檢查主要是通過AbstractSecurityIntercepter來實現(xiàn),進(jìn)入這個攔截器的基礎(chǔ)一定是在context有有效的Authentication。
回顧下上面實現(xiàn)的UserDetailsService,在登錄或token認(rèn)證時返回的Authentication包含了GrantedAuthority的列表。

@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //調(diào)用roles("USER")會將USER角色加入GrantedAuthority
        return User.builder().username("Jack").password(passwordEncoder.encode("jack-password")).roles("USER").build();  
    }

然后我們上面的配置類中有對url的role做了配置。比如下面的配置表示/admin開頭的url支持有admin和manager權(quán)限的用戶訪問:

.antMatchers("/admin/**").hasAnyRole("ADMIN,MANAGER") 

對于Intecepter來說只需要吧配置中的信息和GrantedAuthority的信息一起提交給AccessDecisionManager來做比對。

跨域支持

前后端分離的項目需要支持跨域請求,需要做下面的配置。
CORS配置
首先需要在HttpSecurity配置中啟用cors支持

http.cors()

這樣spring security就會從CorsConfigurationSource中取跨域配置,所以我們需要定義一個Bean:

@Bean
    protected CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST","HEAD", "OPTION"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.addExposedHeader("Authorization");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

Header配置
對于返回給瀏覽器的Response的Header也需要添加跨域配置:

http..headers().addHeaderWriter(new StaticHeadersWriter(Arrays.asList(
         //支持所有源的訪問
        new Header("Access-control-Allow-Origin","*"),
         //使ajax請求能夠取到header中的jwt token信息
        new Header("Access-Control-Expose-Headers","Authorization"))))

OPTIONS請求配置
對于ajax的跨域請求,瀏覽器在發(fā)送真實請求之前,會向服務(wù)端發(fā)送OPTIONS請求,看服務(wù)端是否支持。對于options請求我們只需要返回header,不需要再進(jìn)其它的filter,所以我們加了一個OptionsRequestFilter,填充header后就直接返回:

public class OptionsRequestFilter extends OncePerRequestFilter{

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if(request.getMethod().equals("OPTIONS")) {
            response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,HEAD");
            response.setHeader("Access-Control-Allow-Headers", response.getHeader("Access-Control-Request-Headers"));
            return;
        }
        filterChain.doFilter(request, response);
    }

}

總結(jié)

Spring Security在和shiro使用了類似的認(rèn)證核心設(shè)計的情況下,提供了更多的和web的整合,以及更豐富的第三方認(rèn)證支持。同時在安全性方面,也提供了足夠多的默認(rèn)支持,對得上security這個名字。
所以這兩個框架的選擇問題就相對簡單了:
1)如果系統(tǒng)中本來使用了spring,那優(yōu)先選擇spring security;
2)如果是web系統(tǒng),spring security提供了更多的安全性支持
3)除次之外可以選擇shiro

文章內(nèi)使用的源碼已經(jīng)放在git上:Spring Security and JWT demo

[參考資料]
Spring Security Reference

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容