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清空。舉例來說明為何要清空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 {
    @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 {

    @Autowired
    private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
    @Autowired
    private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;

    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    @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(), 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 Security addFilter() 順序問題

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

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

SpringBoot實戰派-第十章源碼


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM