SpringSecurity+jwt,實現前後分離 適配Resful API的權限控制

前言

SpringSecurity默認採用的基於表單的認證形式,以session識別 從用戶登錄授權、鑑權等都與表單相關。而當前許多應用都採用SpringBoot 基於Resful API風格的開發,使用默認的SpringSecurity無法直接使用滿足。解決方案:不採用默認的登錄鑑權方式,而通過覆寫增加其中的過濾器Filter來實現 登錄和鑑權,並將JWT(JSON WEB TOKEN)作爲認證機制。

原理:

  1. 首次使用username+password請求登錄接口
  2. 驗證成功後向server生成並向client返回一個定製化jwt
  3. 後續clinet的每次HTTP請求都攜帶 header=Authorization, value=jwt
  4. server接收到請求將獲取jwt中的驗證信息,生成一個在SpringSecurity認證體系中的UsernamePasswordAuthenticationToken (此Token與返給客戶端的jwt不同)

實現步驟:

  • JWTLoginFilter implement UsernamePasswordAuthenticationFilter (登錄過濾器)
  • JWTAuthenticationFilter extends BasicAuthenticationFilter (token檢驗過濾器)
  • JWTUtils (操作jwt的工具類)

源碼地址:https://github.com/YoungerJam/SpringSecurityWithJwtDemo

重點

// JWTLoginFilter.java

package com.SpringSecurityWithJwt.demo.filter;

import com.SpringSecurityWithJwt.demo.entity.User;
import com.SpringSecurityWithJwt.demo.utils.JwtUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

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

/**
 * 登錄過濾器 client發送POST請求到此驗證
 * 驗證成功後將生成返回token給client
 *
 * @Author: Jam
 * @Date: 2020/3/12 17:24
 */
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {


    private AuthenticationManager authenticationManager;

    /**
     * 將登錄請求的路徑設置爲 /auth (默認下的 /login)
     * @param authenticationManager 認證管理
     */
    public JWTLoginFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        super.setFilterProcessesUrl("/auth");
    }

    /**
     * 重點覆寫的方法,方法名直接譯 嘗試認證
     * @param request 攜帶 用戶名+密碼 的請求體
     * @param response
     * @return 認證信息
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            User user = new User();
            user.setUsername(request.getParameter("username"));
            user.setPassword(request.getParameter("password"));
            return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
                    user.getUsername(),
                    user.getPassword(),
                    new ArrayList<>()
            ));
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 覆寫成功認證的方法,若賬號密碼校驗成功,會調用到此方法
     * 並生成對應的token返回給client
     * @param request 
     * @param response 返回給client的響應體,(會在頭部加上token認證令牌,此後的訪問需攜帶此token)
     * @param chain
     * @param auth
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication auth)throws  IOException, ServletException{
        Collection<? extends GrantedAuthority> authorities=auth.getAuthorities();
        String username=((org.springframework.security.core.userdetails.User) auth.getPrincipal()).getUsername();
        String role="";
        for(GrantedAuthority authority:authorities){
            role=authority.getAuthority();
        }
        System.out.println(username+" "+role);
        String token=JwtUtils.createToken(username,role);
        response.addHeader(JwtUtils.TOKEN_HEADER, JwtUtils.TOKEN_PREFIX+token);
    }
}

// JWTAuthenticationFilter.java

package com.SpringSecurityWithJwt.demo.filter;

import com.SpringSecurityWithJwt.demo.utils.JwtUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

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

/**
 * token校驗過濾器
 * 攜帶token的http請求將會在此類進行token校驗
 * jwt提供了token檢驗機制
 *
 * @Author: Jam
 * @Date: 2020/3/12 18:00
 */

public class JWTAuthenticationFilter extends BasicAuthenticationFilter {

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager, AuthenticationEntryPoint authenticationEntryPoint) {
        super(authenticationManager, authenticationEntryPoint);
    }

    /**
     * 覆寫方法
     * @param request 攜帶token的請求體
     * @param response
     * @param chain
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String header=request.getHeader(JwtUtils.TOKEN_HEADER);
        //過濾沒有token的(可能是無需授權的訪問)
        if(header==null||!header.startsWith(JwtUtils.TOKEN_PREFIX)){
            chain.doFilter(request,response);
            return;
        }
        UsernamePasswordAuthenticationToken authentication = getAuthentication(header);
        //設置該用戶的認證信息,由jwtToken生成UsernamePasswordAuthenticationToken
        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(request, response);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(String header) {
        String token = header.replace(JwtUtils.TOKEN_PREFIX, "");
        String username = JwtUtils.getUsername(token);
        String role = JwtUtils.getUserRole(token);
        System.out.println("授權:"+username+" "+role);
        if (username != null) {
            return new UsernamePasswordAuthenticationToken(username, null,
                    Collections.singleton(new SimpleGrantedAuthority(role))
            );
        }
        return null;
    }

}

// JwtUtils.java

package com.SpringSecurityWithJwt.demo.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

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

/**
 * @Author: Jam
 * @Date: 2020/3/13 0:36
 */
public class JwtUtils {
    public static final String TOKEN_HEADER="Authorization";
    public static final String TOKEN_PREFIX="Bearer ";
    private static final String SECRET="MyJwtSecret";
    private static final long EXPIRATION=7200;
    private static final String ROLE_CLAIMS="rol";

    public static String createToken(String username,String role){
        Map<String,Object> map=new HashMap<>();
        map.put(ROLE_CLAIMS,role);
        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512,SECRET)
                .setClaims(map)
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis()+EXPIRATION*1000))
                .compact();
    }
    public static String getUsername(String token){
        return getTokenBody(token).getSubject();
    }
    public static String getUserRole(String token){
        return (String) getTokenBody(token).get(ROLE_CLAIMS);
    }
   
    //解析jwt
    private static Claims getTokenBody(String token){
        return Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token)
                .getBody();
    }
}

// JWTAuthenticationEntryPoint 異常處理類

package com.SpringSecurityWithJwt.demo.exception;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

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

/**
 * @Author: Jam
 * @Date: 2020/3/13 15:09
 */
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write(new ObjectMapper().writeValueAsString("無訪問權限 "+authException.getMessage()));
    }
}

// SecurityConfig.java

package com.SpringSecurityWithJwt.demo.config;

import com.SpringSecurityWithJwt.demo.exception.JWTAuthenticationEntryPoint;
import com.SpringSecurityWithJwt.demo.filter.JWTAuthenticationFilter;
import com.SpringSecurityWithJwt.demo.filter.JWTLoginFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.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 javax.sql.DataSource;

/**
 * @Author: Jam
 * @Date: 2020/3/9 16:31
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final DataSource dataSource;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Autowired
    public SecurityConfig(DataSource dataSource, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.dataSource = dataSource;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable().authorizeRequests()
                .antMatchers(HttpMethod.GET, "/user/query").authenticated()
                .antMatchers(HttpMethod.POST, "/user/register").permitAll()
                .antMatchers(HttpMethod.POST, "/user/update").hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .addFilter(new JWTLoginFilter(authenticationManager()))
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint());
    }

    /**
    * 這裏我採用的是jdbc的數據庫表認證
    **/
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .jdbcAuthentication()
                .dataSource(dataSource)
                .usersByUsernameQuery(
                        "select username,password,true from t_user where username=?"
                )
                .authoritiesByUsernameQuery(
                        "select username,'ROLE_USER' from t_user where username=?"

                )
                .passwordEncoder(new BCryptPasswordEncoder());
    }
}

測試

  1. 開放接口 /user/register
    在這裏插入圖片描述
    開放接口能正常調用,注意爲post請求 在配置時設置了只有 post方法下的該路徑才方通,而 get /user/registrer 則屬於配置.anyRequest().authenticated()
    所以如果手誤以get 請求該路徑,會返回無授權訪問的信息
    在這裏插入圖片描述
  2. 登錄接口 /auth
    在這裏插入圖片描述
    由於沒有覆寫成功的返回體信息,所以Reponse中的body是空的,有需要可以自行添加,重點關注reponse Header中的Authorization 這個頭部即爲服務器返回的 jwt token。
  3. GET /user/query 和 POST /user/update接口測試
    在這裏插入圖片描述
    不攜帶token時響應如上,在Header中加上剛剛登錄時返回的Authorzation
    在這裏插入圖片描述
    在這裏插入圖片描述
    測試完成,權限控制正常發揮作用,返回idea可以看到剛剛處理過程中的一些權限相關信息 (我在兩個FIlter裏寫的sout)
    在這裏插入圖片描述

剖析

實現過程中,通過繼承UsernamePasswordAuthenticationFilter覆寫了attemptAuthenticaton和successfulAuthentication方法,來看看源碼中的該類
(截取重點部分)
在這裏插入圖片描述
只接接受 /login路徑下的post請求 原則是post請求不可更改,而路徑可以隨意設置 如上述代碼中的 super.setFilterProcessesUrl("/auth");
在這裏插入圖片描述
在這裏插入圖片描述
這裏可以看到 其實在源碼實現層面上,內部使用的便是 UsernamePasswordAuthenticationToken 這也是爲什麼我們在攜帶jwt再次請求的時候要解析jwt生成 APAT(英文簡寫吧),所以不需要調到attempt方法但仍然可以具備授權身份。
在這裏插入圖片描述
// 截留了源碼中的關鍵語句


	private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
	private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
protected void successfulAuthentication(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, Authentication authResult)
			throws IOException, ServletException {
SecurityContextHolder.getContext().setAuthentication(authResult);
successHandler.onAuthenticationSuccess(request, response, authResult);
	}

驗證成功的處理方法,處於AbstractAuthenticat… Filter中,有興趣可以自己調出來看看,默認是調用Handler類處理後續,所以如果選擇不覆寫此方法的話可以自己去是寫一個實現Hadnler類也能達到想要的自定義登錄成功/失敗處理。

再來看看JWTAuthentication的父類 BsicAuthenticationFitler
點開後 - - 有很大一篇的備註,看看重點
在這裏插入圖片描述我們只需要關注兩個關鍵點
1、我們攜帶token的請求被誰攔截處理(如上圖)所以我們可以擴展它
2、怎麼處理這個token 並完成識別用戶權限。

在上面我們提到了
SecurityContextHolder.getContext().setAuthentication(authResult);
這個語句便是設置認證信息的關鍵所在,再來會看一下這個頻繁提到的UsernamePasswordAuthenticationToken到底是怎樣的數據結構
該類有個主體字段 principal
輸出一下驗證賬號密碼後生成的UPAT看看

org.springframework.security.core.userdetails.User@a0ccb02b: 
Username: 771007760;
Password: [PROTECTED]; 
Enabled: true; 
AccountNonExpired: true; 
credentialsNonExpired: true;
AccountNonLocked: true; 
Granted Authorities: ROLE_USER

幾乎囊括了用戶的的所有信息。該類的一個構造器(上述在JWTAuthenticationFilter調用的構造器)

在這裏插入圖片描述
我們攜帶的JwtToken解析生成回 UPAT,只需寫入username,null,role 便可完成認證。
輸出一下通過token解析反生成的UPAT看看

   System.out.println(u.getPrincipal()+" "+u.getAuthorities());

771007760 [ROLE_USER]
沒有其他的信息,因爲此時這就具備了鑑權條件了 username+role

參考了多方文檔與實現方案 也沒辦法標出全部的借鑑,所以在此感謝所有大佬吧。

源碼地址:https://github.com/YoungerJam/SpringSecurityWithJwtDemo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章