SpringBoot 整合SpringSecurity JWT

ITDragon龍發表於2020-08-19

1. 簡介

今天ITDragon分享一篇在Spring Security 框架中使用JWT,以及對失效Token的處理方法。

1.1 SpringSecurity

Spring Security 是Spring提供的安全框架。提供認證、授權和常見的攻擊防護的功能。功能豐富和強大。

Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.

1.2 OAuth2

OAuth(Open Authorization)開放授權是為使用者資源的授權定義一個安全、開放的標準。而OAuth2是OAuth協議的第二個版本。OAuth常用於第三方應用授權登入。在第三方無需知道使用者賬號密碼的情況下,獲取使用者的授權資訊。常見的授權模式有:授權碼模式、簡化模式、密碼模式和客戶端模式。

1.3 JWT

JWT(json web token)是一個開放的標準,它可以在各方之間作為JSON物件安全地傳輸資訊。可以通過數字簽名進行驗證和信任。JWT可以解決分散式系統登陸授權、單點登入跨域等問題。

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object.

2. SpringBoot 整合 SpringSecurity

SpringBoot 整合Spring Security 非常方便,也是簡單的兩個步驟:導包和配置

2.1 匯入Spring Security 庫

作為Spring的自家專案,只需要匯入spring-boot-starter-security 即可

compile('org.springframework.boot:spring-boot-starter-security')

2.2 配置Spring Security

第一步:建立Spring Security Web的配置類,並繼承web應用的安全介面卡WebSecurityConfigurerAdapter。

第二步:重寫configure方法,可以新增登入驗證失敗處理器、退出成功處理器、並按照ant風格開啟攔截規則等相關配置。

第三步:配置預設或者自定義的密碼加密邏輯、AuthenticationManager、各種過濾器等,比如JWT過濾器。

配置程式碼如下:

package com.itdragon.server.config

import com.itdragon.server.security.service.ITDragonJwtAuthenticationEntryPoint
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
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.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder

@Configuration
@EnableWebSecurity
class ITDragonWebSecurityConfig: WebSecurityConfigurerAdapter() {

    @Autowired
    lateinit var authenticationEntryPoint: ITDragonJwtAuthenticationEntryPoint

    /**
     * 配置密碼編碼器
     */
    @Bean
    fun passwordEncoder(): PasswordEncoder{
        return BCryptPasswordEncoder()
    }

    override fun configure(http: HttpSecurity) {
        // 配置異常處理器
        http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
        		// 配置登出邏輯
                .and().logout()
                .logoutSuccessHandler(logoutSuccessHandler)
                // 開啟許可權攔截
                .and().authorizeRequests()
                // 開放不需要攔截的請求
                .antMatchers(HttpMethod.POST, "/itdragon/api/v1/user").permitAll()
                // 允許所有OPTIONS請求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 允許靜態資源訪問
                .antMatchers(HttpMethod.GET,
                        "/",
                        "/*.html",
                        "/favicon.ico",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                ).permitAll()
                // 對除了以上路徑的所有請求進行許可權攔截
                .antMatchers("/itdragon/api/v1/**").authenticated()
                // 先暫時關閉跨站請求偽造,它限制除了get以外的大多數方法。
                .and().csrf().disable()
        		// 允許跨域請求
                .cors().disable()

    }

}

注意:

  • 1)、csrf防跨站請求偽造的功能是預設開啟,除錯過程中可以先暫時關閉。

  • 2)、logout()退出成功後預設跳轉到/login路由上,對於前後端分離的專案並不友好。

  • 3)、permitAll()方法修飾的配置建議寫在authenticated()方法的上面。

3. SpringSecurity 配置JWT

JWT的優點有很多,使用也很簡單。但是我們ITDragon在使用的過程中也需要注意處理JWT的失效問題。

3.1 匯入JWT庫

Spring Security 整合JWT還需要額外引入io.jsonwebtoken:jjwt 庫

compile('io.jsonwebtoken:jjwt:0.9.1')

3.2 建立JWT工具類

JWT工具類主要負責:

  • 1)、token的生成。建議使用使用者的登入賬號作為生成token的屬性,這是考慮到賬號的唯一性和可讀性都很高。

  • 2)、token的驗證。包括token是否已經自然過期、是否因為人為操作導致失效、資料的格式是否合法等。

程式碼如下:

package com.itdragon.server.security.utils

import com.itdragon.server.security.service.JwtUser
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.util.*

private const val CLAIM_KEY_USERNAME = "itdragon"

@Component
class JwtTokenUtil {

    @Value("\${itdragon.jwt.secret}")
    private val secret: String = "ITDragon"

    @Value("\${itdragon.jwt.expiration}")
    private val expiration: Long = 24 * 60 * 60

    /**
     * 生成令牌Token
     * 1. 建議使用唯一、可讀性高的欄位作為生成令牌的引數
     */
    fun generateToken(username: String): String {
        return try {
            val claims = HashMap<String, Any>()
            claims[CLAIM_KEY_USERNAME] = username
            generateJWT(claims)
        } catch (e: Exception) {
            ""
        }
    }

    /**
     * 校驗token
     * 1. 判斷使用者名稱和token包含的屬性一致
     * 2. 判斷token是否失效
     */
    fun validateToken(token: String, userDetails: UserDetails): Boolean {
        userDetails as JwtUser
        return getUsernameFromToken(token) == userDetails.username && !isInvalid(token, userDetails.model.tokenInvalidDate)
    }

    /**
     * token 失效判斷,依據如下:
     * 1. 關鍵欄位被修改後token失效,包括密碼修改、使用者退出登入等
     * 2. token 過期失效
     */
    private fun isInvalid(token: String, tokenInvalidDate: Date?): Boolean {
        return try {
            val claims = parseJWT(token)
            claims!!.issuedAt.before(tokenInvalidDate) && isExpired(token)
        } catch (e: Exception) {
            false
        }
    }

    /**
     * token 過期判斷,常見邏輯有幾種:
     * 1. 基於本地記憶體,問題是重啟服務失效
     * 2. 基於資料庫,常用的有Redis資料庫,但是頻繁請求也是不小的開支
     * 3. 用jwt的過期時間和當前時間做比較(推薦)
     */
    private fun isExpired(token: String): Boolean {
        return try {
            val claims = parseJWT(token)
            claims!!.expiration.before(Date())
        } catch (e: Exception) {
            false
        }
    }

    /**
     * 從token 中獲取使用者名稱
     */
    fun getUsernameFromToken(token: String): String {
        return try {
            val claims = parseJWT(token)
            claims!![CLAIM_KEY_USERNAME].toString()
        } catch (e: Exception) {
            ""
        }
    }

    /**
     * 生成jwt方法
     */
    fun generateJWT(claims: Map<String, Any>): String {
        return Jwts.builder()
                .setClaims(claims)      // 定義屬性
                .設計如下:(Date())    // 設定發行時間
                .setExpiration(Date(System.currentTimeMillis() + expiration * 1000)) // 設定令牌有效期
                .signWith(SignatureAlgorithm.HS512, secret) // 使用指定的演算法和金鑰對jwt進行簽名
                .compact()              // 壓縮字串
    }

    /**
     * 解析jwt方法
     */
    private fun parseJWT(token: String): Claims? {
        return try {
            Jwts.parser()
                    .setSigningKey(secret)  // 設定金鑰
                    .parseClaimsJws(token)  // 解析token
                    .body
        } catch (e: Exception) {
            null
        }
    }

}

3.3 新增JWT過濾器

新增的JWT過濾器需要實現以下幾個功能:

  • 1)、自定義的JWT過濾器要在Spring Security 提供的使用者名稱密碼過濾器之前執行
  • 2)、要保證需要攔截的請求都必須帶上token資訊
  • 3)、判斷傳入的token是否有效

程式碼如下:

package com.itdragon.server.security.service

import com.itdragon.server.security.utils.JwtTokenUtil
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetailsService
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.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@Component
class ITDragonJwtAuthenticationTokenFilter: OncePerRequestFilter() {

    @Value("\${itdragon.jwt.header}")
    lateinit var tokenHeader: String
    @Value("\${itdragon.jwt.tokenHead}")
    lateinit var tokenHead: String
    @Autowired
    lateinit var userDetailsService: UserDetailsService
    @Autowired
    lateinit var jwtTokenUtil: JwtTokenUtil

    /**
     * 過濾器驗證步驟
     * 第一步:從請求頭中獲取token
     * 第二步:從token中獲取使用者資訊,判斷token資料是否合法
     * 第三步:校驗token是否有效,包括token是否過期、token是否已經重新整理
     * 第四步:檢驗成功後將使用者資訊存放到SecurityContextHolder Context中
     */
    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {

        // 從請求頭中獲取token
        val authHeader = request.getHeader(this.tokenHeader)
        if (authHeader != null && authHeader.startsWith(tokenHead)) {
            val authToken = authHeader.substring(tokenHead.length)
            // 從token中獲取使用者資訊
            val username = jwtTokenUtil.getUsernameFromToken(authToken)
            if (username.isBlank()) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized: Auth token is illegal")
                return
            }
            if (null != SecurityContextHolder.getContext().authentication) {
                val tempUser = SecurityContextHolder.getContext().authentication.principal
                tempUser as JwtUser
                println("SecurityContextHolder : ${tempUser.username}")
            }

            // 驗證token是否有效
            val userDetails = this.userDetailsService.loadUserByUsername(username)
            if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                // 將使用者資訊新增到SecurityContextHolder 的Context
                val authentication = UsernamePasswordAuthenticationToken(userDetails, userDetails.password, userDetails.authorities)
                authentication.details = WebAuthenticationDetailsSource().buildDetails(request)
                SecurityContextHolder.getContext().authentication = authentication
            }
        }

        filterChain.doFilter(request, response)
    }

}

將JWT過濾器新增到UsernamePasswordAuthenticationFilter 過濾器之前

http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter::class.java)

完整的ITDragonWebSecurityConfig類的程式碼如下:

package com.itdragon.server.config

import com.itdragon.server.security.service.ITDragonJwtAuthenticationEntryPoint
import com.itdragon.server.security.service.ITDragonJwtAuthenticationTokenFilter
import com.itdragon.server.security.service.ITDragonLogoutSuccessHandler
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.AuthenticationManager
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.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
class ITDragonWebSecurityConfig: WebSecurityConfigurerAdapter() {

    @Autowired
    lateinit var jwtAuthenticationTokenFilter: ITDragonJwtAuthenticationTokenFilter
    @Autowired
    lateinit var authenticationEntryPoint: ITDragonJwtAuthenticationEntryPoint
    @Autowired
    lateinit var logoutSuccessHandler: ITDragonLogoutSuccessHandler

    @Bean
    fun passwordEncoder(): PasswordEncoder{
        return BCryptPasswordEncoder()
    }

    @Bean
    fun itdragonAuthenticationManager(): AuthenticationManager {
        return authenticationManager()
    }

    /**
     * 第一步:將JWT過濾器新增到預設的賬號密碼過濾器之前,表示token驗證成功後無需登入
     * 第二步:配置異常處理器和登出處理器
     * 第三步:開啟許可權攔截,對所有請求進行攔截
     * 第四步:開放不需要攔截的請求,比如使用者註冊、OPTIONS請求和靜態資源等
     * 第五步:允許OPTIONS請求,為跨域配置做準備
     * 第六步:允許訪問靜態資源,訪問swagger時需要
     */
    override fun configure(http: HttpSecurity) {
        // 新增jwt過濾器
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter::class.java)
                // 配置異常處理器
                .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
                // 配置登出邏輯
                .and().logout()
                .logoutSuccessHandler(logoutSuccessHandler)
                // 開啟許可權攔截
                .and().authorizeRequests()
                // 開放不需要攔截的請求
                .antMatchers(HttpMethod.POST, "/itdragon/api/v1/user").permitAll()
                // 允許所有OPTIONS請求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 允許靜態資源訪問
                .antMatchers(HttpMethod.GET,
                        "/",
                        "/*.html",
                        "/favicon.ico",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                ).permitAll()
                // 對除了以上路徑的所有請求進行許可權攔截
                .antMatchers("/itdragon/api/v1/**").authenticated()
                // 先暫時關閉跨站請求偽造,它限制除了get以外的大多數方法。
                .and().csrf().disable()
                // 允許跨域請求
                .cors().disable()

    }

}

3.4 登入驗證

程式碼如下:

package com.itdragon.server.security.service

import com.itdragon.server.security.utils.JwtTokenUtil
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.stereotype.Service

@Service
class ITDragonAuthService {
    @Autowired
    lateinit var authenticationManager: AuthenticationManager
    @Autowired
    lateinit var userDetailsService: UserDetailsService
    @Autowired
    lateinit var jwtTokenUtil: JwtTokenUtil

    fun login(username: String, password: String): String {
        // 初始化UsernamePasswordAuthenticationToken物件
        val upAuthenticationToken = UsernamePasswordAuthenticationToken(username, password)
        // 身份驗證
        val authentication = authenticationManager.authenticate(upAuthenticationToken)
        // 驗證成功後回將使用者資訊存放到 securityContextHolder的Context中
        SecurityContextHolder.getContext().authentication = authentication
        // 生成token並返回
        val userDetails = userDetailsService.loadUserByUsername(username)
        return jwtTokenUtil.generateToken(userDetails.username)
    }

}

3.5 關於JWT失效處理

Token的失效包括常見的過期失效、重新整理失效、修改密碼失效還有就是使用者登出失效(有的場景不需要)

ITDragon是以JWT自帶的建立時間和到期時間、與傳入的時間做判斷。來判斷token是否失效,這樣可以減少和資料庫的互動。

解決自然過期的token失效設計如下:

  • 1)、生成token時,設定setExpiration屬性

  • 1)、校驗token時,通過獲取expiration屬性,並和當前時間做比較,若在當前時間之前則說明token已經過期

解決人為操作上的token失效設計如下:

  • 1)、生成token時,設定setIssuedAt屬性
  • 2)、使用者表新增tokenInvalidDate欄位。在重新整理token、修改使用者密碼等操作時,更新這個欄位
  • 3)、校驗token時,通過獲取issuedAt屬性,並和tokenInvalidDate時間做比較,若在tokenInvalidDate時間之前則說明token已經失效

程式碼如下:

/**
     * token 失效判斷,依據如下:
     * 1. 關鍵欄位被修改後token失效,包括密碼修改、使用者退出登入等
     * 2. token 過期失效
     */
private fun isInvalid(token: String, tokenInvalidDate: Date?): Boolean {
    return try {
        val claims = parseJWT(token)
        claims!!.issuedAt.before(tokenInvalidDate) && isExpired(token)
    } catch (e: Exception) {
        false
    }
}

/**
     * token 過期判斷,常見邏輯有幾種:
     * 1. 基於本地記憶體,問題是系統重啟後失效
     * 2. 基於資料庫,常用的有Redis資料庫,但是頻繁請求也是不小的開支
     * 3. 用jwt的過期時間和當前時間做比較(推薦)
     */
private fun isExpired(token: String): Boolean {
    return try {
        val claims = parseJWT(token)
        claims!!.expiration.before(Date())
    } catch (e: Exception) {
        false
    }
}

文章到這裡就結束了,感謝各位看官!!???

完整程式碼訪問GitHub地址:https://github.com/ITDragonBlog/daydayup/tree/master/SpringBoot/spring-boot-springsecurity-jwt

專案所在目錄可能會發生變化,但是https://github.com/ITDragonBlog/daydayup 地址不會變

相關文章