SpringSecurity許可權管理系統實戰—六、SpringSecurity整合JWT

codermy發表於2020-08-19

目錄

SpringSecurity許可權管理系統實戰—一、專案簡介和開發環境準備
SpringSecurity許可權管理系統實戰—二、日誌、介面文件等實現
SpringSecurity許可權管理系統實戰—三、主要頁面及介面實現
SpringSecurity許可權管理系統實戰—四、整合SpringSecurity(上)
SpringSecurity許可權管理系統實戰—五、整合SpringSecurity(下)
SpringSecurity許可權管理系統實戰—六、SpringSecurity整合jwt
SpringSecurity許可權管理系統實戰—七、處理一些問題
SpringSecurity許可權管理系統實戰—八、AOP 記錄使用者日誌、異常日誌

前言

最近是真的懶,感覺我每個月都有那麼幾天什麼都不想幹。。

畫風一轉,前幾天的lpl忍界大戰是真的精彩,虛假的電競春晚:RNG vs IG 。真正的電競春晚 TES vs IG。TES自從阿水和kasra加入之後,狀態直接起飛,在我看來TES將是s10奪冠熱門之一。不過這一次木葉村戰勝了曉組織。

本以為會打滿三局,沒想到ig直接2:0帶走。rookie線上壓制了新皇knight,確實永遠可以相信宋義進,或許是因為‍小鈺採訪吧。

這兩把我最沒想到的是kasra被寧王壓著打,幾乎沒有節奏,寶藍在哪都是阿水的噩夢。這波啊,這波是盜版打贏了正版,puff小小的證明了自己。

最後還是希望lpl的飯圈粉少一點,peace

在這裡插入圖片描述

進入正題

一、無狀態登入

  • 有狀態登入

    我們知道在原始的專案中我們是通過session和cookie來實現使用者的識別認證。但是這樣做無疑會增加伺服器的壓力,服務的儲存了大量的資料。如果業務需要擴充套件,搭建了叢集的話,還需要將session共享。

  • 無狀態登入

    而什麼是無狀態登入呢,簡而言之,就是服務的不需要再儲存任何的使用者資訊,而是使用者自己攜帶者資訊去訪問服務端,服務端通過這些資訊來識別客戶端身份。這樣一來,有狀態登入的缺點都被解決了,但是這同樣也會帶來新問題。比如token資訊無法在服務端登出,必須要等其自己過期,佔用更多的空間(意味著需要更多頻寬),修改密碼後原本的token在沒過期時仍然可用訪問系統等。

二、JWT介紹

1、什麼是jwt

JWT是 Json Web Token 的縮寫。它是基於 RFC 7519 標準定義的一種可以安全傳輸的 小巧 和 自包含 的JSON物件。由於資料是使用數字簽名的,所以是可信任的和安全的。JWT可以使用HMAC演算法對secret進行加密或者使用RSA的公鑰私鑰對來進行簽名。

我們來看一下jwt長什麼樣

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA

JSON Web 令牌以緊湊的形式由三個部分組成,由點分隔,它們包括:

  • 頭部
  • 負載
  • 簽名

頭部(Header)

jwt的頭部承載兩部分資訊:

  • 宣告型別,這裡是jwt
  • 宣告加密的演算法 通常直接使用 HMAC SHA256

像這樣

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

載荷(Payload)

這個部分用來承載要傳遞的資料,他的預設欄位有

  • iss:發行人
  • exp:到期時間
  • sub:主題
  • aud:使用者
  • nbf:在此之前不可用
  • iat:釋出時間
  • jti:JWT ID用於標識該JWT

除以上預設欄位外,我們還可以自定義私有欄位,例如

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

簽名(Signature)

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

2、JWT工作流程

  • 使用者發起登入請求
  • 服務端驗證身份,將使用者資訊,標識等資訊打包成jwt token返回給客戶端
  • 使用者拿到token,攜帶token傳送請求給服務端
  • 服務的驗證token是否可用,可用便根據其y業務邏輯返回相應結果。

3、簡單實現

首先我們在maven中引入以下依賴

		<!--jjwt-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

新建JwtTest來測試一下

/**
 * @author codermy
 * @createTime 2020/7/30
 */
public class JwtTest {
    public static void main(String[] args) {
        String token = Jwts.builder()
                //使用者名稱
                .setSubject("codermy")
                //自定義屬性 放入使用者擁有請求許可權
                .claim("authorities","admin")
                // 設定失效時間為1分鐘
                .setExpiration(new Date(System.currentTimeMillis()+1000*60))
                // 簽名演算法和金鑰
                .signWith(SignatureAlgorithm.HS512, "java")
                .compact();
        System.out.println(token);
    }

輸出

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA

我們再來解析

	//解析token
        Claims claims = Jwts.parser()
                .setSigningKey("java")
                .parseClaimsJws(token)
                .getBody();
        System.out.println(claims);
        //獲取使用者名稱
        String username = claims.getSubject();
        System.out.println("username:"+username);
        //獲取許可權
        String authority = claims.get("authorities").toString();
        System.out.println("許可權:"+authority);
        System.out.println("到期時間:" + claims.getExpiration());

輸出

{sub=codermy, authorities=admin, exp=1596082316}
username:codermy
許可權:admin
到期時間:Thu Jul 30 12:11:56 CST 2020

三、整合JWT

後端實現

其實jwt本身很好理解,無非就就是一把鑰匙,可用開啟對應的鎖,這不過這把鑰匙稍微特殊點,它還帶了主人的一些資訊。難理解的是要將它符合業務邏輯的整合進框架中。我自己就被繞了好久才明白。

我這裡寫了一個Jwt的工具類,用於生成和解析jwt

/**
 * @author codermy
 * @createTime 2020/7/23
 */
@Component
public class JwtUtils {
    private static final String CLAIM_KEY_USERNAME = "sub";
    private static final String CLAIM_KEY_CREATED = "created";
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private  Long expiration;
    // 建立token
    public  String generateToken(String username) {
        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, secret)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();

    }
    // 從token中獲取使用者名稱
    public  String getUserNameFromToken(String token){
        return getTokenBody(token).getSubject();
    }

    // 是否已過期
    public  boolean isExpiration(String token){
        return getTokenBody(token).getExpiration().before(new Date());
    }

    private  Claims getTokenBody(String token){
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

}

然後我們可以將jwt的一些資訊寫在yml中,使得可以靈活的配置。application.yml中新增如下配置

jwt:
  tokenHeader: Authorization #JWT儲存的請求頭
  secret: my-springsecurity-plus #JWT加解密使用的金鑰
  expiration: 604800 #JWT的超期限時間(60*60*24*7)
  tokenHead: 'Bearer ' #JWT負載中拿到開頭,空格別忘了

我們照著jwt的工作流程來,首先是登入成功後客戶端會返回一個jwt token

所以我們首先自定義一個MyAuthenticationSuccessHandler繼承AuthenticationSuccessHandler,這是登入成功後的處理器

/**
 * @author codermy
 * @createTime 2020/8/1
 * 登入成功
 */
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private JwtUtils jwtUtils;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {

        JwtUserDto userDetails = (JwtUserDto)authentication.getPrincipal();//拿到登入使用者資訊
        String jwtToken = jwtUtils.generateToken(userDetails.getUsername());//生成token
        Result result = Result.ok().message("登入成功").jwt(jwtToken);
        System.out.println(JSON.toJSONString(result));//用於測試
        httpServletResponse.setCharacterEncoding("utf-8");//修改編碼格式
        httpServletResponse.setContentType("application/json");
        httpServletResponse.getWriter().write(JSON.toJSONString(result));//輸出結果
        httpServletResponse.sendRedirect("/api/admin");//重定向到api/admin頁面。我這裡路由名取的不是很好
    }
}

然後我們再寫一個jwt的攔截器,讓每個請求都需要驗證jwt token

/**
 * @author codermy
 * @createTime 2020/7/30
 */
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private UserDetailsServiceImpl userDetailsService;
    @Autowired
    private JwtUtils jwtUtils;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;

    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.tokenHeader);//拿到requset中的head
        if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
            String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
            String username = jwtUtils.getUserNameFromToken(authToken);//解析token獲取使用者名稱
            log.info("checking username:{}", username);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                if (userDetails != null) {//判斷是否存在這個給使用者
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    log.info("authenticated user:{}", username);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
    }

這裡為了之後結果更直觀,自定義一個AuthenticationEntryPoint,用於在未登入是訪問介面返回json而不是login.html

/**
 * @author codermy
 * @createTime 2020/8/1
 * 當未登入或者token失效訪問介面時,自定義的返回結果
 */
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");//設定編碼格式
        response.setContentType("application/json");
        response.getWriter().println(JSON.toJSONString(Result.error().message("尚未登入,或者登入過期   " + authException.getMessage())));
        response.getWriter().flush();
    }
}

將上述方法加入到SpringSecurityConfig中

/**
 * @author codermy
 * @createTime 2020/7/15
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsServiceImpl userDetailsService;
    @Autowired
    private VerifyCodeFilter verifyCodeFilter;
    @Autowired
    MyAuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Autowired
    private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    @Autowired
    private RestfulAccessDeniedHandler accessDeniedHandler;
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    /**
     * 身份認證介面
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }


    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                .antMatchers(HttpMethod.GET,
                        "/swagger-resources/**",
                        "/PearAdmin/**",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/swagger-ui.html",
                        "/webjars/**",
                        "/v2/**");//放行靜態資源
    }

    /**
     * anyRequest          |   匹配所有請求路徑
     * access              |   SpringEl表示式結果為true時可以訪問
     * anonymous           |   匿名可以訪問
     * denyAll             |   使用者不能訪問
     * fullyAuthenticated  |   使用者完全認證可以訪問(非remember-me下自動登入)
     * hasAnyAuthority     |   如果有引數,參數列示許可權,則其中任何一個許可權可以訪問
     * hasAnyRole          |   如果有引數,參數列示角色,則其中任何一個角色可以訪問
     * hasAuthority        |   如果有引數,參數列示許可權,則其許可權可以訪問
     * hasIpAddress        |   如果有引數,參數列示IP地址,如果使用者IP和引數匹配,則可以訪問
     * hasRole             |   如果有引數,參數列示角色,則其角色可以訪問
     * permitAll           |   使用者可以任意訪問
     * rememberMe          |   允許通過remember-me登入的使用者訪問
     * authenticated       |   使用者登入後可訪問
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
        http.csrf().disable()//關閉csrf
                .sessionManagement()// 基於token,所以不需要session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .httpBasic().authenticationEntryPoint(restAuthenticationEntryPoint)//未登陸時返回 JSON 格式的資料給前端,否則是html
                .and()
                .authorizeRequests()
                .antMatchers("/captcha").permitAll()//任何人都能訪問這個請求
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html")//登入頁面 不設限訪問
                .loginProcessingUrl("/login")//攔截的請求
                .successHandler(authenticationSuccessHandler) // 登入成功處理器
                .permitAll()
                // 防止iframe 造成跨域
                .and()
                .headers()
                .frameOptions()
                .disable()
                .and();

        // 禁用快取
        http.headers().cacheControl();

        // 新增JWT攔截器
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);


}

我這裡直接貼了完整的程式碼,因為有新增也有刪除,不是很好描述,大家對比著之前的來看,都新增了註釋。

現在我們重啟專案,用admin賬號來登入。登入成功後發現頁面並沒有跳轉到我們想去的頁面,但是控制檯列印出了我們想要的jwt資訊

{"code":200,"data":[],"jwt":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTU5NjI1OTgyOCwiZXhwIjoxNTk2ODY0NjI4fQ.Khn5t6WjOsuG6R2if1Q_gAeNq-zTamIAO32b1UVc6L8-6_IAHMaCeWr-v7H2-7Hob0SSmmK23dv71_da-YK8hw","msg":"登入成功","success":true}

這是為什麼呢?

著很好理解,因為我們的jwt攔截器已經起了作用,而我們原本的前端頁面是沒有把jwt token新增在header上的,所以認為沒有登入,重定向到了登入頁面。

但是我們現在可以藉助postman來測試,postman是一個測試api的工具,大家可以自行百度,這裡不做過多介紹。

在我們未攜帶jwt token資訊時,訪問http://localhost:8080/api/menu介面,就會報如下錯誤

在這裡插入圖片描述

我們在header中新增上,之前登入成功控制檯列印的token資訊(因為我們新增了圖片驗證碼,所以登入不是很方便用postman,我們可以在瀏覽器中登入或者先把驗證碼的攔截器去除)

在這裡插入圖片描述

加上了token資訊之後再去訪問http://localhost:8080/api/menu介面,發現已經可以正常訪問了

在這裡插入圖片描述

我們再嘗試用test使用者登入後獲取到jwt token訪問該介面,會報如下錯誤
在這裡插入圖片描述

修改Swagger配置

直接貼程式碼

/**
 * @author codermy
 * @createTime 2020/7/10
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;

    @Value("${jwt.tokenHead}")
    private String tokenHead;


    @Bean
    public Docket createRestApi() {
        ParameterBuilder ticketPar = new ParameterBuilder();
        List<Parameter> pars = new ArrayList<>();
        ticketPar.name(tokenHeader).description("token")
                .modelRef(new ModelRef("string"))
                .parameterType("header")
                .defaultValue(tokenHead + " ")
                .required(true)
                .build();
        pars.add(ticketPar.build());
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(webApiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.codermy.myspringsecurityplus.controller"))
                .paths(PathSelectors.any())
                .paths(Predicates.not(PathSelectors.regex("/error.*")))

                .build()
                .globalOperationParameters(pars);
    }
    /**
     * 該套 API 說明,包含作者、簡介、版本、等資訊
     * @return
     */
    private ApiInfo webApiInfo(){
        return new ApiInfoBuilder()
                .title("my-springsecurity-plus-API文件")
                .description("本文件描述了my-springsecurity-plus介面定義")
                .version("1.0.5")
                .build();
    }

}

現在再swagger中就可以新增token測試了
在這裡插入圖片描述

前端適配

那麼我們現在已經簡單的實現了jwt的無狀態登入功能,需要做的就是讓前端的請求都帶上jwt token。

。。。研究了半天沒弄懂,所以暫時先擱置,下一章解決它。有知道怎麼設定請求頭的小夥伴也可以留言告訴我

所以本章結束的程式碼是不能正常在瀏覽器執行的,但是可以在postman和swagger中測試(如果想執行,在SpringSecurityConfig中新增上.rememberMe()即可)

giteegithub中可獲取原始碼,與本系列文章同步更新

相關文章