將SpringBoot+SpringSecurity改造爲前後端分離+Jwt的權限認證系統,Token過期刷新問題

前言

一般來說,我們用SpringSecurity默認的話是前後端整在一起的,比如thymeleaf或者Freemarker,SpringSecurity還自帶login登錄頁,還讓你配置登出頁,錯誤頁。
但是現在前後端分離纔是正道,前後端分離的話,那就需要將返回的頁面換成Json格式交給前端處理了

SpringSecurity默認的是採用Session來判斷請求的用戶是否登錄的,但是不方便分佈式的擴展,雖然SpringSecurity也支持採用SpringSession來管理分佈式下的用戶狀態,不過現在分佈式的還是無狀態的Jwt比較主流。 所以下面說下怎麼讓SpringSecurity變成前後端分離,採用Jwt來做認證的

一、五個handler一個filter兩個User

5個handler,分別是

  • 實現AuthenticationEntryPoint接口,當匿名請求需要登錄的接口時,攔截處理
  • 實現AuthenticationSuccessHandler接口,當登錄成功後,該處理類的方法被調用
  • 實現AuthenticationFailureHandler接口,當登錄失敗後,該處理類的方法被調用
  • 實現AccessDeniedHandler接口,當登錄後,訪問接口沒有權限的時候,該處理類的方法被調用
  • 實現LogoutSuccessHandler接口,註銷的時候調用

1.1 AuthenticationEntryPoint

匿名未登錄的時候訪問,遇到需要登錄認證的時候被調用

/**
 * 匿名未登錄的時候訪問,需要登錄的資源的調用類
 * @author zzzgd
 */
@Component
public class CustomerAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
	    //設置response狀態碼,返回錯誤信息等
	    ...
        ResponseUtil.out(401, ResultUtil.failure(ErrorCodeConstants.REQUIRED_LOGIN_ERROR));
    }
}

1.2 AuthenticationSuccessHandler

這裏是我們輸入的用戶名和密碼登錄成功後,調用的方法
簡單的說就是獲取用戶信息,使用JWT生成token,然後返回token


/**
 * 登錄成功處理類,登錄成功後會調用裏面的方法
 * @author Exrickx
 */
@Slf4j
@Component
public class CustomerAuthenticationSuccessHandler implements AuthenticationSuccessHandler {


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    	//簡單的說就是獲取當前用戶,拿到用戶名或者userId,創建token,返回
        log.info("登陸成功...");
        CustomerUserDetails principal = (CustomerUserDetails) authentication.getPrincipal();
        //頒發token
        Map<String,Object> emptyMap = new HashMap<>(4);
        emptyMap.put(UserConstants.USER_ID,principal.getId());
        String token = JwtTokenUtil.generateToken(principal.getUsername(), emptyMap);
        ResponseUtil.out(ResultUtil.success(token));
    }
}

1.3 AuthenticationFailureHandler

有登陸成功就有登錄失敗
登錄失敗的時候調用這個方法,可以在其中做登錄錯誤限制或者其他操作,我這裏直接就是設置響應頭的狀態碼爲401,返回


/**
 * 登錄賬號密碼錯誤等情況下,會調用的處理類
 * @author Exrickx
 */
@Slf4j
@Component
public class CustomerAuthenticationFailHandler implements AuthenticationFailureHandler {


    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
    //設置response狀態碼,返回錯誤信息等
    	....
        ResponseUtil.out(401, ResultUtil.failure(ErrorCodeConstants.LOGIN_UNMATCH_ERROR));
    }

}

1.4 LogoutSuccessHandler

登出註銷的時候調用,Jwt有個缺點就是無法主動控制失效,可以採用Jwt+session的方式,比如刪除存儲在Redis的token

這裏需要注意,如果將SpringSecurity的session配置爲無狀態,或者不保存session,這裏authentication爲null!! ,注意空指針問題。(詳情見下面的配置WebSecurityConfigurerAdapter)

/**
 * 登出成功的調用類
 * @author zzzgd
 */
@Component
public class CustomerLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        ResponseUtil.out(ResultUtil.success("Logout Success!"));
    }
}

1.5 AccessDeniedHandler

登錄後,訪問缺失權限的資源會調用。

/**
 * 沒有權限,被拒絕訪問時的調用類
 * @author Exrickx
 */
@Component
@Slf4j
public class CustomerRestAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
        ResponseUtil.out(403, ResultUtil.failure(ErrorCodeConstants.PERMISSION_DENY));
    }

}

1.6 一個過濾器OncePerRequestFilter

這裏算是一個小重點。
上面我們在登錄成功後,返回了一個token,那怎麼使用這個token呢?

前端發起請求的時候將token放在請求頭中,在過濾器中對請求頭進行解析。

  1. 如果有accessToken的請求頭(可以自已定義名字),取出token,解析token,解析成功說明token正確,將解析出來的用戶信息放到SpringSecurity的上下文中
  2. 如果有accessToken的請求頭,解析token失敗(無效token,或者過期失效),取不到用戶信息,放行
  3. 沒有accessToken的請求頭,放行

這裏可能有人會疑惑,爲什麼token失效都要放行呢?
這是因爲SpringSecurity會自己去做登錄的認證和權限的校驗,靠的就是我們放在SpringSecurity上下文中的SecurityContextHolder.getContext().setAuthentication(authentication);,沒有拿到authentication,放行了,SpringSecurity還是會走到認證和校驗,這個時候就會發現沒有登錄沒有權限。

舊版本, 最新在底部

package com.zgd.shop.web.config.auth.filter;

import com.zgd.shop.common.constants.SecurityConstants;
import com.zgd.shop.common.util.jwt.JwtTokenUtil;
import com.zgd.shop.web.config.auth.user.CustomerUserDetailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 過濾器,在請求過來的時候,解析請求頭中的token,再解析token得到用戶信息,再存到SecurityContextHolder中
 * @author zzzgd
 */
@Component
@Slf4j
public class CustomerJwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    CustomerUserDetailService customerUserDetailService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        
    	//請求頭爲 accessToken
    	//請求體爲 Bearer token

    	String authHeader = request.getHeader(SecurityConstants.HEADER);

        if (authHeader != null && authHeader.startsWith(SecurityConstants.TOKEN_SPLIT)) {

            final String authToken = authHeader.substring(SecurityConstants.TOKEN_SPLIT.length());
            String username = JwtTokenUtil.parseTokenGetUsername(authToken);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = customerUserDetailService.loadUserByUsername(username);
                if (userDetails != null) {
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

1.7 實現UserDetails擴充字段

這個接口表示的用戶信息,SpringSecurity默認實現了一個User,不過字段寥寥無幾,只有username,password這些,而且後面獲取用戶信息的時候也是獲取的UserDetail。

於是我們將自己的數據庫的User作爲拓展,自己實現這個接口。繼承的是數據庫對應的User,而不是SpringSecurity的User

package com.zgd.shop.web.config.auth.user;

import com.zgd.shop.common.constants.UserConstants;
import com.zgd.shop.dao.entity.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

/**
 * CustomerUserDetails
 *
 * @author zgd
 * @date 2019/7/17 15:29
 */
public class CustomerUserDetails extends User implements UserDetails {

  private Collection<? extends GrantedAuthority> authorities;

  public CustomerUserDetails(User user){
    this.setId(user.getId());
    this.setUsername(user.getUsername());
    this.setPassword(user.getPassword());
    this.setRoles(user.getRoles());
    this.setStatus(user.getStatus());
  }

  public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
    this.authorities = authorities;
  }

  /**
   * 添加用戶擁有的權限和角色
   * @return
   */
  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return this.authorities;
  }

  /**
   * 賬戶是否過期
   * @return
   */
  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  /**
   * 是否禁用
   * @return
   */
  @Override
  public boolean isAccountNonLocked() {
    return  true;
  }

  /**
   * 密碼是否過期
   * @return
   */
  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  /**
   * 是否啓用
   * @return
   */
  @Override
  public boolean isEnabled() {
    return UserConstants.USER_STATUS_NORMAL.equals(this.getStatus());
  }
}

1.8 實現UserDetailsService

SpringSecurity在登錄的時候,回去數據庫(或其他來源),根據username獲取正確的user信息,就會根據這個service類,拿到用戶的信息和權限。我們自己實現

package com.zgd.shop.web.config.auth.user;

import com.alibaba.fastjson.JSON;
import com.zgd.shop.dao.entity.model.User;
import com.zgd.shop.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * @author zgd
 * @date 2019/1/16 16:27
 * @description 自己實現UserDetailService,用與SpringSecurity獲取用戶信息
 */
@Service
@Slf4j
public class CustomerUserDetailService implements UserDetailsService {

  @Autowired
  private IUserService userService;

  /**
   * 獲取用戶信息,然後交給spring去校驗權限
   * @param username
   * @return
   * @throws UsernameNotFoundException
   */
  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    //獲取用戶信息
    User user = userService.getUserRoleByUserName(username);
    if(user == null){
      throw new UsernameNotFoundException("用戶名不存在");
    }
    CustomerUserDetails customerUserDetails = new CustomerUserDetails(user);

    List<SimpleGrantedAuthority> authorities = new ArrayList<>();
    //用於添加用戶的權限。只要把用戶權限添加到authorities 就萬事大吉。
    if (CollectionUtils.isNotEmpty(user.getRoles())){
      user.getRoles().forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_"+r.getRoleName())));
    }
    customerUserDetails.setAuthorities(authorities);
    log.info("authorities:{}", JSON.toJSONString(authorities));
    
    //這裏返回的是我們自己定義的UserDetail
    return customerUserDetails;
  }
}

二、配置WebSecurityConfigurerAdapter

我們需要將上面定義的handler和filter,註冊到SpringSecurity。同時配置一些放行的url

這裏有一點需要注意:如果配置了下面的SessionCreationPolicy.STATELESS,則SpringSecurity不會保存session會話,在/logout登出的時候會拿不到用戶實體對象。

http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

如果登出註銷不依賴SpringSecurity,並且session交給redis的token來管理的話,可以按上面的配置。

package com.zgd.shop.web.config;

import com.zgd.shop.web.config.auth.encoder.MyAesPasswordEncoder;
import com.zgd.shop.web.config.auth.encoder.MyEmptyPasswordEncoder;
import com.zgd.shop.web.config.auth.handler.*;
import com.zgd.shop.web.config.auth.filter.CustomerJwtAuthenticationTokenFilter;
import com.zgd.shop.web.config.auth.user.CustomerUserDetailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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;

/**
 * @Author: zgd
 * @Date: 2019/1/15 17:42
 * @Description:
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)// 控制@Secured權限註解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

  /**
   * 這裏需要交給spring注入,而不是直接new
   */
  @Autowired
  private PasswordEncoder passwordEncoder;
  @Autowired
  private CustomerUserDetailService customerUserDetailService;
  @Autowired
  private CustomerAuthenticationFailHandler customerAuthenticationFailHandler;
  @Autowired
  private CustomerAuthenticationSuccessHandler customerAuthenticationSuccessHandler;
  @Autowired
  private CustomerJwtAuthenticationTokenFilter customerJwtAuthenticationTokenFilter;
  @Autowired
  private CustomerRestAccessDeniedHandler customerRestAccessDeniedHandler;
  @Autowired
  private CustomerLogoutSuccessHandler customerLogoutSuccessHandler;
  @Autowired
  private CustomerAuthenticationEntryPoint customerAuthenticationEntryPoint;


 
  /**
   * 該方法定義認證用戶信息獲取的來源、密碼校驗的規則
   *
   * @param auth
   * @throws Exception
   */
  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    //auth.authenticationProvider(myauthenticationProvider)  自定義密碼校驗的規則

    //如果需要改變認證的用戶信息來源,我們可以實現UserDetailsService
    auth.userDetailsService(customerUserDetailService).passwordEncoder(passwordEncoder);
  }


  @Override
  protected void configure(HttpSecurity http) throws Exception {
    /**
     * antMatchers: ant的通配符規則
     * ?	匹配任何單字符
     * *	匹配0或者任意數量的字符,不包含"/"
     * **	匹配0或者更多的目錄,包含"/"
     */
    http
            .headers()
            .frameOptions().disable();

    http
            //登錄後,訪問沒有權限處理類
            .exceptionHandling().accessDeniedHandler(customerRestAccessDeniedHandler)
            //匿名訪問,沒有權限的處理類
            .authenticationEntryPoint(customerAuthenticationEntryPoint)
    ;

    //使用jwt的Authentication,來解析過來的請求是否有token
    http
            .addFilterBefore(customerJwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);


    http
            .authorizeRequests()
            //這裏表示"/any"和"/ignore"不需要權限校驗
            .antMatchers("/ignore/**", "/login", "/**/register/**").permitAll()
            .anyRequest().authenticated()
            // 這裏表示任何請求都需要校驗認證(上面配置的放行)


            .and()
            //配置登錄,檢測到用戶未登錄時跳轉的url地址,登錄放行
            .formLogin()
            //需要跟前端表單的action地址一致
            .loginProcessingUrl("/login")
            .successHandler(customerAuthenticationSuccessHandler)
            .failureHandler(customerAuthenticationFailHandler)
            .permitAll()

            //配置取消session管理,又Jwt來獲取用戶狀態,否則即使token無效,也會有session信息,依舊判斷用戶爲登錄狀態
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            //配置登出,登出放行
            .and()
            .logout()
            .logoutSuccessHandler(customerLogoutSuccessHandler)
            .permitAll()
            
            .and()
            .csrf().disable()
    ;
  }


}

三、其他

大概到這就差不多了,啓動,localhost:8080/login,使用postman,採用form-data,post提交,參數是username和password,調用,返回token。

將token放在header中,請求接口。ok

3.1 不足之處

上面是最簡單的處理,還有很多優化的地方。比如

  1. 控制token銷燬?
    使用redis+token組合,不僅解析token,還判斷redis是否有這個token。註銷和主動失效token:刪除redis的key
  2. 控制token過期時間?如果用戶在token過期前1秒還在操作,下1秒就需要重新登錄,肯定不好
    1、考慮加入refreshToken,過期時間比token長,前端在拿到token的同時獲取過期時間,在過期前一分鐘用refreshToken調用refresh接口,重新獲取新的token。
    2、 將返回的jwtToken設置短一點的過期時間,redis再存這個token,過期時間設置長一點。如果請求過來token過期,查詢redis,如果redis還存在,返回新的token。(爲什麼redis的過期時間大於token的?因爲redis的過期是可控的,手動可刪除,以redis的爲準)
  3. 每次請求都會被OncePerRequestFilter 攔截,每次都會被UserDetailService中的獲取用戶數據請求數據庫,可以考慮做緩存,還是用redis或者直接保存內存中

3.2 解決

更新 2019-07-19

這是針對上面的2.2說的,也就是redis時間久一點,jwt過期後如果redis沒過期,頒發新的jwt。
不過更推薦的是前端判斷過期時間,在過期之前調用refresh接口拿到新的jwt

爲什麼這樣?
如果redis過期時間是一週,jwt是一個小時,那麼一個小時後,拿着這個過期的jwt去調,就可以想創建多少個新的jwt就創建,只要沒過redis的過期時間。 當然這是在沒對過期的jwt做限制的情況下,如果要考慮做限制,比如對redis的value加一個字段,保存當前jwt,刷新後就用新的jwt覆蓋,refresh接口判斷當前的過期jwt是不是和redis這個一樣。

總之還需要判斷刷新token的時候,過期jwt是否合法的問題。總不能去年的過期token也拿來刷新吧。
而在過期前去刷新token的話,至少不會發生這種事情

不過我這裏自己寫demo,採用的還是2.2的方式,也就是過期後給個新的,思路如下:

  1. 登錄後頒發token,token有個時間戳,同時以username拼裝作爲key,保存這個時間戳到緩存(redis,cache)
  2. 請求來了,過濾器解析token,沒過期的話,還需要比較緩存中的時間戳和token的時間戳是不是一樣 ,如果時間戳不一樣,說明該token不能刷新。無視
  3. 註銷,清除緩存數據

這樣就可以避免token過期後,我還能拿到這個token無限制的refresh。

不過這個還是有細節方面問題,併發下同時刷新token這些並沒有考慮,部分代碼如下

舊版本, 最新在底部

package com.zgd.shop.web.auth.filter;

import com.zgd.shop.common.constants.SecurityConstants;
import com.zgd.shop.common.util.jwt.JwtTokenUtil;
import com.zgd.shop.web.auth.user.CustomerUserDetailService;
import com.zgd.shop.web.auth.user.CustomerUserDetails;
import com.zgd.shop.web.auth.user.UserSessionService;
import com.zgd.shop.web.auth.user.UserTokenManager;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 過濾器,在請求過來的時候,解析請求頭中的token,再解析token得到用戶信息,再存到SecurityContextHolder中
 * @author zzzgd
 */
@Component
@Slf4j
public class CustomerJwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    CustomerUserDetailService customerUserDetailService;
    @Autowired
    UserSessionService userSessionService;
    @Autowired
    UserTokenManager userTokenManager;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        
    	//請求頭爲 accessToken
    	//請求體爲 Bearer token

    	String authHeader = request.getHeader(SecurityConstants.HEADER);

        if (authHeader != null && authHeader.startsWith(SecurityConstants.TOKEN_SPLIT)) {

            final String authToken = authHeader.substring(SecurityConstants.TOKEN_SPLIT.length());

            String username;
            Claims claims;
            try {
                claims = JwtTokenUtil.parseToken(authToken);
                username = claims.getSubject();
            } catch (ExpiredJwtException e) {
                //token過期
                claims = e.getClaims();
                username = claims.getSubject();
                CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username);
                if (userDetails != null){
                    //session未過期,比對時間戳是否一致,是則重新頒發token
                    if (isSameTimestampToken(username,e.getClaims())){
                        userTokenManager.awardAccessToken(userDetails,true);
                    }
                }
            }
            //避免每次請求都請求數據庫查詢用戶信息,從緩存中查詢
            CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//                UserDetails userDetails = customerUserDetailService.loadUserByUsername(username);
                if (userDetails != null) {
                    if(isSameTimestampToken(username,claims)){
                        //必須token解析的時間戳和session保存的一致
                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
        }
        chain.doFilter(request, response);
    }

    /**
     * 判斷是否同一個時間戳
     * @param username 
     * @param claims
     * @return
     */
    private boolean isSameTimestampToken(String username, Claims claims){
        Long timestamp = userSessionService.getTokenTimestamp(username);
        Long jwtTimestamp = (Long) claims.get(SecurityConstants.TIME_STAMP);
        return timestamp.equals(jwtTimestamp);
    }
}
package com.zgd.shop.web.auth.user;

import com.google.common.collect.Maps;
import com.zgd.shop.common.constants.SecurityConstants;
import com.zgd.shop.common.constants.UserConstants;
import com.zgd.shop.common.util.ResponseUtil;
import com.zgd.shop.common.util.jwt.JwtTokenUtil;
import com.zgd.shop.core.result.ResultUtil;
import com.zgd.shop.web.config.auth.UserAuthProperties;
import org.apache.commons.collections.MapUtils;
import org.checkerframework.checker.units.qual.A;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 * UserTokenManager
 * token管理
 *
 * @author zgd
 * @date 2019/7/19 15:25
 */
@Component
public class UserTokenManager {

  @Autowired
  private UserAuthProperties userAuthProperties;
  @Autowired
  private UserSessionService userSessionService;

  /**
   * 頒發token
   * @param principal
   * @author zgd
   * @date 2019/7/19 15:34
   * @return void
   */
  public void awardAccessToken(CustomerUserDetails principal,boolean isRefresh) {
    //頒發token 確定時間戳,保存在session中和token中
    long mill = System.currentTimeMillis();
    userSessionService.saveSession(principal);
    userSessionService.saveTokenTimestamp(principal.getUsername(),mill);

    Map<String,Object> param = new HashMap<>(4);
    param.put(UserConstants.USER_ID,principal.getId());
    param.put(SecurityConstants.TIME_STAMP,mill);

    String token = JwtTokenUtil.generateToken(principal.getUsername(), param,userAuthProperties.getJwtExpirationTime());
    HashMap<String, String> map = Maps.newHashMapWithExpectedSize(1);
    map.put(SecurityConstants.HEADER,token);
    int code = isRefresh ? 201 : 200;
    ResponseUtil.outWithHeader(code,ResultUtil.success(),map);
  }
}

更新

2019-09-30

針對token解析的過濾器做了優化:

  1. 如果redis的session沒過期, 但是請求頭的token過期了, 判斷時間戳一致後, 頒發新token並返回
  2. 如果redis的session沒過期, 但是請求頭的token過期了, 時間戳不一致, 說明當前請求的token無法刷新token, 設置響應碼爲401返回
  3. 如果請求頭的token過期了, 但是redis的session失效或未找到, 直接放行, 交給後面的權限校驗處理(也就是沒有給上下文SecurityContextHolder設置登錄信息, 後面如果判斷這個請求缺少權限會自行處理)
package com.zgd.shop.web.auth.filter;

import com.zgd.shop.common.constants.SecurityConstants;
import com.zgd.shop.common.util.ResponseUtil;
import com.zgd.shop.common.util.jwt.JwtTokenUtil;
import com.zgd.shop.core.error.ErrorCodeConstants;
import com.zgd.shop.core.result.ResultUtil;
import com.zgd.shop.web.auth.user.CustomerUserDetailService;
import com.zgd.shop.web.auth.user.CustomerUserDetails;
import com.zgd.shop.web.auth.user.UserSessionService;
import com.zgd.shop.web.auth.user.UserTokenManager;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 過濾器,在請求過來的時候,解析請求頭中的token,再解析token得到用戶信息,再存到SecurityContextHolder中
 * @author zzzgd
 */
@Component
@Slf4j
public class CustomerJwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    CustomerUserDetailService customerUserDetailService;
    @Autowired
    UserSessionService userSessionService;
    @Autowired
    UserTokenManager userTokenManager;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        
    	//請求頭爲 accessToken
    	//請求體爲 Bearer token

    	String authHeader = request.getHeader(SecurityConstants.HEADER);

        if (authHeader != null && authHeader.startsWith(SecurityConstants.TOKEN_SPLIT)) {
            //請求頭有token
            final String authToken = authHeader.substring(SecurityConstants.TOKEN_SPLIT.length());

            String username;
            Claims claims;
            try {
                claims = JwtTokenUtil.parseToken(authToken);
                username = claims.getSubject();
            } catch (ExpiredJwtException e) {
                //token過期
                claims = e.getClaims();
                username = claims.getSubject();
                CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username);
                if (userDetails != null){
                    //session未過期,比對時間戳是否一致,是則重新頒發token
                    if (isSameTimestampToken(username,e.getClaims())){
                        userTokenManager.awardAccessToken(userDetails,true);
                        //直接設置響應碼爲201,直接返回
                        return;
                    }else{
                        //時間戳不一致.無效token,無法刷新token,響應碼401,前端跳轉登錄頁
                        ResponseUtil.out(HttpStatus.UNAUTHORIZED.value(),ResultUtil.failure(ErrorCodeConstants.REQUIRED_LOGIN_ERROR));
                        return;
                    }
                }else{
                    //直接放行,交給後面的handler處理,如果當前請求是需要訪問權限,則會由CustomerRestAccessDeniedHandler處理
                    chain.doFilter(request, response);
                    return;
                }
            }

            //避免每次請求都請求數據庫查詢用戶信息,從緩存中查詢
            CustomerUserDetails userDetails = userSessionService.getSessionByUsername(username);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//                UserDetails userDetails = customerUserDetailService.loadUserByUsername(username);
                if (userDetails != null) {
                    if(isSameTimestampToken(username,claims)){
                        //必須token解析的時間戳和session保存的一致
                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
        }
        chain.doFilter(request, response);
    }

    /**
     * 判斷是否同一個時間戳
     * @param username
     * @param claims
     * @return
     */
    private boolean isSameTimestampToken(String username, Claims claims){
        Long timestamp = userSessionService.getTokenTimestamp(username);
        Long jwtTimestamp = (Long) claims.get(SecurityConstants.TIME_STAMP);
        return timestamp.equals(jwtTimestamp);
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章