1. 程式人生 > 實用技巧 >Spring Security5整合JWT認證和授權

Spring Security5整合JWT認證和授權

JWT介紹

JWT原理

JWT是JSON Web Token的縮寫,是目前最流行的跨域認證解決方法。

網際網路服務認證的一般流程是:

  1. 使用者向伺服器傳送賬號、密碼
  2. 伺服器驗證通過後,將使用者的角色、登入時間等資訊儲存到當前會話中
  3. 同時,伺服器向用戶返回一個session_id(一般儲存在cookie裡)
  4. 使用者再次傳送請求時,把含有session_id的cookie傳送給伺服器
  5. 伺服器收到session_id,查詢session,提取使用者資訊

上面的認證模式,存在以下缺點:

  • cookie不允許跨域
  • 因為每臺伺服器都必須儲存session物件,所以擴充套件性不好

JWT認證原理是:

  1. 使用者向伺服器傳送賬號、密碼
  2. 伺服器驗證通過後,生成token令牌返回給客戶端(token可以包含使用者資訊)
  3. 使用者再次請求時,把token放到請求頭Authorization
  4. 伺服器收到請求,驗證token合法後放行請求

JWT token令牌可以包含使用者身份、登入時間等資訊,這樣登入狀態保持者由伺服器端變為客戶端,伺服器變成無狀態了;token放到請求頭,實現了跨域

JWT資料結構

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT由三部分組成:

  • Header(頭部)
  • Payload(負載)
  • Signature(簽名)

表現形式為:Header.Payload.Signature

Header 部分是一個 JSON 物件,描述 JWT 的元資料,通常是下面的樣子:

{
  "alg": "HS256",
  "typ": "JWT"
}

上面程式碼中,alg屬性表示簽名的演算法(algorithm),預設是 HMAC SHA256(寫成 HS256);typ屬性表示這個令牌(token)的型別(type),JWT 令牌統一寫為JWT

上面的 JSON 物件使用 Base64URL 演算法轉成字串

Payload

Payload 部分也是一個 JSON 物件,用來存放實際需要傳遞的資料。JWT 規定了7個官方欄位:

  • iss (issuer):簽發人

  • exp (expiration time):過期時間

  • sub (subject):主題

  • aud (audience):受眾

  • nbf (Not Before):生效時間

  • iat (Issued At):簽發時間

  • jti (JWT ID):編號

當然,使用者也可以定義私有欄位。

這個 JSON 物件也要使用 Base64URL 演算法轉成字串

Signature

Signature 部分是對前兩部分的簽名,防止資料篡改

簽名演算法如下:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

算出簽名以後,把 Header、Payload、Signature 三個部分拼成一個字串,每個部分之間用"."分隔

JWT認證和授權

Security是基於AOP和Servlet過濾器的安全框架,為了實現JWT要重寫那些方法、自定義那些過濾器需要首先了解security自帶的過濾器。security預設過濾器鏈如下:

  1. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
  2. org.springframework.security.web.context.SecurityContextPersistenceFilter
  3. org.springframework.security.web.header.HeaderWriterFilter
  4. org.springframework.security.web.authentication.logout.LogoutFilter
  5. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
  6. org.springframework.security.web.savedrequest.RequestCacheAwareFilter
  7. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
  8. org.springframework.security.web.authentication.AnonymousAuthenticationFilter
  9. org.springframework.security.web.session.SessionManagementFilter
  10. org.springframework.security.web.access.ExceptionTranslationFilter
  11. org.springframework.security.web.access.intercept.FilterSecurityInterceptor

SecurityContextPersistenceFilter

這個過濾器有兩個作用:

  • 使用者傳送請求時,從session物件提取使用者資訊,儲存到SecurityContextHolder的securitycontext中
  • 當前請求響應結束時,把SecurityContextHolder的securitycontext儲存的使用者資訊放到session,便於下次請求時共享資料;同時將SecurityContextHolder的securitycontext清空

由於禁用session功能,所以該過濾器只剩一個作用即把SecurityContextHolder的securitycontext清空。使用者1傳送一個請求,由執行緒M處理,當響應完成執行緒M放回執行緒池;使用者2傳送一個請求,本次請求同樣由執行緒M處理,由於securitycontext沒有清空,理應儲存使用者2的資訊但此時儲存的是使用者1的資訊,造成使用者資訊不符

UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter繼承自AbstractAuthenticationProcessingFilter,處理邏輯在doFilter方法中:

  1. 當請求被UsernamePasswordAuthenticationFilter攔截時,判斷請求路徑是否匹配登入URL,若不匹配繼續執行下個過濾器;否則,執行步驟2
  2. 呼叫attemptAuthentication方法進行認證。UsernamePasswordAuthenticationFilter重寫了attemptAuthentication方法,負責從讀取登入引數,委託AuthenticationManager進行認證,返回一個認證過的token(null表示認證失敗)
  3. 判斷token是否為null,非null表示認證成功,null表示認證失敗
  4. 若認證成功,呼叫successfulAuthentication。該方法把認證過的token放入securitycontext供後續請求授權,同時該方法預留一個擴充套件點(AuthenticationSuccessHandler.onAuthenticationSuccess方法),進行認證成功後的處理
  5. 若認證失敗,同樣可以擴充套件uthenticationFailureHandler.onAuthenticationFailure進行認證失敗後的處理
  6. 只要當前請求路徑匹配登入URL,那麼無論認證成功還是失敗,當前請求都會響應完成,不再執行過濾器鏈

UsernamePasswordAuthenticationFilterattemptAuthentication方法,執行邏輯如下:

  1. 從請求中獲取表單引數。因為使用HttpServletRequest.getParameter方法獲取引數,它只能處理Content-Type為application/x-www-form-urlencoded或multipart/form-data的請求,若是application/json則無法獲取值
  2. 把步驟1獲取的賬號、密碼封裝成UsernamePasswordAuthenticationToken物件,建立未認證的token。UsernamePasswordAuthenticationToken有兩個過載的構造方法,其中public UsernamePasswordAuthenticationToken(Object principal, Object credentials)建立未經認證的token,public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)建立已認證的token
  3. 獲取認證管理器AuthenticationManager,其預設實現為ProviderManager,呼叫其authenticate進行認證
  4. ProviderManagerauthenticate是個模板方法,它遍歷所有AuthenticationProvider,直至找到支援認證某型別token的AuthenticationProvider,呼叫AuthenticationProvider.authenticate方法認證,AuthenticationProvider.authenticate載入正確的賬號、密碼進行比較驗證
  5. AuthenticationManager.authenticate方法返回一個已認證的token

AnonymousAuthenticationFilter

AnonymousAuthenticationFilter負責建立匿名token:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            SecurityContextHolder.getContext().setAuthentication(this.createAuthentication((HttpServletRequest)req));
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.of(() -> {
                    return "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication();
                }));
            } else {
                this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
            }
        } else if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.of(() -> {
                return "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication();
            }));
        }

        chain.doFilter(req, res);
    }

如果當前使用者沒有認證,會建立一個匿名token,使用者是否能讀取資源交由FilterSecurityInterceptor過濾器委託給決策管理器判斷是否有許可權讀取

實現思路

JWT認證思路:

  1. 利用Security原生的表單認證過濾器驗證使用者名稱、密碼
  2. 驗證通過後自定義AuthenticationSuccessHandler認證成功處理器,由該處理器生成token令牌

JWT授權思路:

  1. 使用JWT目的是讓伺服器變成無狀態,不用session共享資料,所以要禁用security的session功能(http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  2. token令牌資料結構設計時,payload部分要儲存使用者名稱、角色資訊
  3. token令牌有兩個作用:
    1. 認證, 使用者傳送的token合法即代表認證成功
    2. 授權,令牌驗證成功後提取角色資訊,構造認證過的token,將其放到securitycontext,具體許可權判斷交給security框架處理
  4. 自己實現一個過濾器,攔截使用者請求,實現(3)中所說的功能

程式碼實現

建立JWT工具類

JWT的Java實現,利用開源的java-jwt

<dependency>
	<groupId>com.auth0</groupId>
	<artifactId>java-jwt</artifactId>
	<version>3.12.0</version>
 </dependency>

我們對java-jwt提供的API進行封裝,便於建立、驗證、提取claim

@Slf4j
public class JWTUtil {
    // 攜帶token的請求頭名字
    public final static String TOKEN_HEADER = "Authorization";
    //token的字首
    public final static String TOKEN_PREFIX = "Bearer ";
    // 預設金鑰
    public final static String DEFAULT_SECRET = "mySecret";
    // 使用者身份
    private final static String ROLES_CLAIM = "roles";
    // token有效期,單位分鐘;
    private final static long EXPIRE_TIME = 5 * 60 * 1000;
    // 設定Remember-me功能後的token有效期
    private final static long EXPIRE_TIME_REMEMBER = 7 * 24 * 60 * 60 * 1000;

    // 建立token
    public static String createToken(String username, List role, String secret, boolean rememberMe) {

        Date expireDate = rememberMe ? new Date(System.currentTimeMillis() + EXPIRE_TIME_REMEMBER) : new Date(System.currentTimeMillis() + EXPIRE_TIME);
        try {
            // 建立簽名的演算法例項
            Algorithm algorithm = Algorithm.HMAC256(secret);
            String token = JWT.create()
                    .withExpiresAt(expireDate)
                    .withClaim("username", username)
                    .withClaim(ROLES_CLAIM, role)
                    .sign(algorithm);
            return token;
        } catch (JWTCreationException jwtCreationException) {
            log.warn("Token create failed");
            return null;
        }
    }

    // 驗證token
    public static boolean verifyToken(String token, String secret) {
        try{
            Algorithm algorithm = Algorithm.HMAC256(secret);
            // 構建JWT驗證器,token合法同時pyload必須含有私有欄位username且值一致
            // token過期也會驗證失敗
            JWTVerifier verifier = JWT.require(algorithm)
                    .build();
            // 驗證token
            DecodedJWT decodedJWT = verifier.verify(token);
            return true;
        } catch (JWTVerificationException jwtVerificationException) {
            log.warn("token驗證失敗");
            return false;
        }

    }

    // 獲取username
    public static String getUsername(String token) {
        try {
            // 因此獲取載荷資訊不需要金鑰
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException jwtDecodeException) {
            log.warn("提取使用者姓名時,token解碼失敗");
            return null;
        }
    }

    public static List<String> getRole(String token) {
        try {
            // 因此獲取載荷資訊不需要金鑰
            DecodedJWT jwt = JWT.decode(token);
            // asList方法需要指定容器元素的型別
            return jwt.getClaim(ROLES_CLAIM).asList(String.class);
        } catch (JWTDecodeException jwtDecodeException) {
            log.warn("提取身份時,token解碼失敗");
            return null;
        }
    }
}

認證

驗證賬號、密碼交給UsernamePasswordAuthenticationFilter,不用修改程式碼

認證成功後,需要生成token返回給客戶端,我們通過擴充套件AuthenticationSuccessHandler.onAuthenticationSuccess方法實現

@Component
public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        ResponseData responseData = new ResponseData();
        responseData.setCode("200");
        responseData.setMessage("登入成功!");
		
        // 提取使用者名稱,準備寫入token
        String username = authentication.getName();
        // 提取角色,轉為List<String>物件,寫入token
        List<String> roles = new ArrayList<>();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority authority : authorities){
            roles.add(authority.getAuthority());
        }
		
        // 建立token
        String token = JWTUtil.createToken(username, roles, JWTUtil.DEFAULT_SECRET, true);
        httpServletResponse.setCharacterEncoding("utf-8");
        // 為了跨域,把token放到響應頭WWW-Authenticate裡
        httpServletResponse.setHeader("WWW-Authenticate", JWTUtil.TOKEN_PREFIX + token);
		// 寫入響應裡
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(httpServletResponse.getWriter(), responseData);
    }
}

為了統一返回值,我們封裝了一個ResponseData物件

授權

自定義一個過濾器JWTAuthorizationFilter,驗證token,token驗證成功後認為認證成功

@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {
    private UserDetailsService userDetailsService;
    // 父類沒有無參構造器,必須顯示呼叫父類的含參構造器
    public JWTAuthorizationFilter(UserDetailsService userDetailsService) {
        super();
        this.userDetailsService = userDetailsService;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String token = getTokenFromRequestHeader(request);
        Authentication verifyResult = verefyToken(token, JWTUtil.DEFAULT_SECRET);
        if (verifyResult == null) {
            // 即便驗證失敗,也繼續呼叫過濾鏈,匿名過濾器生成匿名令牌
            chain.doFilter(request, response);
            return;
        } else {
            log.info("token令牌驗證成功");
            SecurityContextHolder.getContext().setAuthentication(verifyResult);
            chain.doFilter(request, response);
        }
    }
	
    // 從請求頭獲取token
    private String getTokenFromRequestHeader(HttpServletRequest request) {
        String header = request.getHeader(JWTUtil.TOKEN_HEADER);
        if (header == null || !header.startsWith(JWTUtil.TOKEN_PREFIX)) {
            log.info("請求頭不含JWT token, 呼叫下個過濾器");
            return null;
        }

        String token = header.split(" ")[1].trim();
        return token;
    }
	
    // 驗證token,並生成認證後的token
    private UsernamePasswordAuthenticationToken verefyToken(String token, String secret) {
        if (token == null) {
            return null;
        }
		
        // 認證失敗,返回null
        if (!JWTUtil.verifyToken(token, secret)) {
            return null;
        }

        // 提取使用者名稱
        String username = JWTUtil.getUsername(token);
        // 定義許可權列表
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 從token提取角色
        List<String> roles = JWTUtil.getRole(token);
        for (String role : roles) {
            log.info("使用者身份是:" + role);
            authorities.add(new SimpleGrantedAuthority(role));
        }
        // 構建認證過的token
        return new UsernamePasswordAuthenticationToken(username, null, authorities);
    }
}

OncePerRequestFilter保證當前請求中,此過濾器只被呼叫一次,執行邏輯在doFilterInternal

security配置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /*
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 明文密碼
        //auth.inMemoryAuthentication().withUser("user").password("{noop}123456").roles();
        auth.inMemoryAuthentication().withUser("admin").password(passwordEncoder().encode("123456")).roles();
    }*/

    @Autowired
    private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;

    @Autowired
    private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler;

    @Autowired
    private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;

    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    @Autowired
    private CustomUserDetailService customUserDetailService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .successHandler(jwtAuthenticationSuccessHandler)
                .failureHandler(ajaxAuthenticationFailureHandler)
                .permitAll()
                .and()
                .addFilterAfter(new JWTAuthorizationFilter(customUserDetailService), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(ajaxAuthenticationEntryPoint);

    }
}

配置裡取消了session功能,把我們定義的過濾器新增到過濾鏈中;同時,定義ajaxAuthenticationEntryPoint處理未認證使用者訪問未授權資源時丟擲的異常

@Component
public class AjaxAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseData responseData = new ResponseData();
        responseData.setCode("401");
        responseData.setMessage("匿名使用者,請先登入再訪問!");

        httpServletResponse.setCharacterEncoding("utf-8");
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(httpServletResponse.getWriter(), responseData);
    }
}

參考

JSON Web Token 入門教程

Spring Security-5-認證流程梳理

[Spring Security3原始碼分析(5)-SecurityContextPersistenceFilter分析](Spring Security3原始碼分析(5)-SecurityContextPersistenceFilter分析)

Spring Security addFilter() 順序問題

前後端聯調之Form Data與Request Payload,你真的瞭解嗎?

Spring Boot 2 + Spring Security 5 + JWT 的單頁應用 Restful 解決方案

SpringBoot實戰派-第十章原始碼