iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 30
0
Software Development

Kotlin 島深度之旅 30 天系列 第 30

[Day 30] Kotlin Journey - Kotlin + Spring Boot : JWT 認證

今天要來為我們的 API 加上 JWT token 認證

什麼是 JWT

看別人的文章就可以啦!這部分不多作解釋

Spring security

在 Spring 的生態體系中,認證當然就要倚靠強大的 Spring securtiy,但強大歸強大,Spring securtiy 複雜的類別和介面也是一個很讓人頭大的問題。

加上 dependencies

要使用 Spring security 以及 JWT lib 要在 build.gradle.kts 多加上

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("io.jsonwebtoken:jjwt-api:0.11.2")
    implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
    implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")
....
}

實作

SecurityConfiguration 繼承WebSecurityConfigurerAdapter

建立一個 SecurityConfiguration 然後繼承 WebSecurityConfigurerAdapter,記得 @EnableWebSecurity 要加上。

@EnableGlobalMethodSecurity(prePostEnabled = true) 可以讓我們之後在每個 API 上面還可以限制角色存取。

在這 class 有注入一個 TokenProvider,之後 token 建立驗證等的動作都會放在這。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration(
        private val tokenProvider: TokenProvider
) : WebSecurityConfigurerAdapter() {

    // 注入密碼要用來加密的方式
    @Bean
    fun passwordEncoder() = BCryptPasswordEncoder()

    // Spring Security 忽略這些 url 不做驗證
    override fun configure(web: WebSecurity?) {
        web!!.ignoring()
                .antMatchers(HttpMethod.OPTIONS, "/**")
                .antMatchers("/app/**/*.{js,html}")
                .antMatchers("/i18n/**")
                .antMatchers("/content/**")
                .antMatchers("/h2-console/**")
                .antMatchers("/swagger-ui/index.html")
                .antMatchers("/test/**")
    }

    @Throws(Exception::class)
    public override fun configure(http: HttpSecurity) {
        http
                .csrf() // 因為是做 token 驗證,不用開啟避免 csrf
                .disable()
                .headers()
                .frameOptions() // 防止 IFrame 式 Clickjacking 攻擊,上方已經許可 "/h2-console/**"(有用到 iframe) 所以可以正常顯示
                .deny()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 無狀態的 session 政策,不使用 HTTPSession
                .and()
                .authorizeRequests()
                .antMatchers("/api/user/register").permitAll() // 註冊時不做認證
                .antMatchers("/api/authenticate").permitAll() // 取 token 時不做認證
                .antMatchers("/api/**").authenticated() // 其他都要做認證
                .antMatchers("/management/**").hasAuthority(ADMIN) // 只有 ADMIN 角色可以看 acutator 的管理 url
                .and()
                .httpBasic()
                .and()
                .apply(securityConfigurerAdapter()) // 其實裡面就是要做 jwt 的 filter, 在 filter 的過程中驗證 token
    }

    private fun securityConfigurerAdapter() = JWTConfigurer(tokenProvider)
}

繼承 UserDetailsService

以前在 SecurityConfiguration 裡面還會多做一個 AuthenticationManagerBuilder,在這邊使用自己實現的 userDetailsService (從 db 撈資料做做帳號密碼驗證)

// Java code
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
    authenticationManagerBuilder
            .userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder());
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

但現在發現直接這樣做,AuthenticationManagerBuilder 也可以取得這個內容,這裡覆寫了 loadUserByUsername 內容是我們取得 user 物件的方式,最後回傳。

/**
 * Authenticate a user from the database.
 */
@Component("userDetailsService")
class DomainUserDetailsService(private val userRepository: UserRepository) : UserDetailsService, Logging {

    @Transactional
    override fun loadUserByUsername(username: String): UserDetails {
        log().debug("Authenticating $username")

        return userRepository.findByUsername(username)
                .map { createSpringSecurityUser(it) }
                .orElseThrow { UsernameNotFoundException("User $username was not found in the database") }
    }

    private fun createSpringSecurityUser(user: User):
            org.springframework.security.core.userdetails.User {

        val grantedAuthorities = user.authorities.map { SimpleGrantedAuthority(it.name) }
        return org.springframework.security.core.userdetails.User(
                user.username,
                user.password,
                grantedAuthorities
        )
    }
}

TokenProvider

TokenProvider 要做的事,產生 token,驗證 token,產生 Authentication 這裡也就是 UsernamePasswordAuthenticationToken 物件,包含帳號密碼在裡面。

private const val AUTHORITIES_KEY = "auth"

@Component
class TokenProvider() : Logging {

    // 定義在設定檔的 secret
    @Value("\${demo.jwt.base64Secret}")
    private val base64Secret: String? = null

    // 定義在設定檔的 token 有效時間
    @Value("\${demo.jwt.expiresSecond}")
    private val expiresSecond: Long = 0

    private var key: Key? = null

    private var tokenValidityInMilliseconds: Long = 0

    // 在 TokenProvider Bean 所有必要的屬性設定完成後要做這些初始化
    // secret 是 BASE64 編碼的 要解開
    @PostConstruct
    fun init() {
        val keyBytes: ByteArray
        log().info("base64Secret is:$base64Secret")

        val base64Secret = base64Secret ?: throw RuntimeException("secret is null")
        keyBytes = Decoders.BASE64.decode(base64Secret)

        this.key = Keys.hmacShaKeyFor(keyBytes)
        this.tokenValidityInMilliseconds = expiresSecond

    }

    // 使用 jjwt lib 產生 token
    fun createToken(authentication: Authentication): String {
        val authorities = authentication.authorities.asSequence()
                .map { it.authority }
                .joinToString(separator = ",")

        val now = Date().time
        val validity = Date(now + this.tokenValidityInMilliseconds)

        return Jwts.builder()
                .setSubject(authentication.name)
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact()
    }

    fun getAuthentication(token: String): Authentication {
        val claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .body

        val authorities = claims[AUTHORITIES_KEY].toString().splitToSequence(",")
                .mapTo(mutableListOf()) { SimpleGrantedAuthority(it) }

        val principal = User(claims.subject, "", authorities)

        return UsernamePasswordAuthenticationToken(principal, token, authorities)
    }

    fun validateToken(authToken: String): Boolean {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(authToken)

            return true
        } catch (e: JwtException) {
            log().info("Invalid JWT token.")
            log().trace("Invalid JWT token trace. $e")
        } catch (e: IllegalArgumentException) {
            log().info("Invalid JWT token.")
            log().trace("Invalid JWT token trace. $e")
        }

        return false
    }
}

JWTConfigurer

JWTConfigurer 覆寫 SecurityConfigurerAdapter 的 configure 方法,只要是為了把 JWTFilter 加在 已經註冊好的 UsernamePasswordAuthenticationFilter 之前

class JWTConfigurer(private val tokenProvider: TokenProvider) :
    SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>() {

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity?) {
        val customFilter = JWTFilter(tokenProvider)
        http!!.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter::class.java)
    }
}

JWTFilter

JWTFilter 裡面就會利用 TokenProvider 的 validateToken() 來驗證 token ,通過後會取得 authentication,加到 SecurityContextHolder,存到 SecurityContextHolder 是整個 Spring security 的最後一個步驟,SecurityContextHolder 存了認證的狀態,和角色權限使用者內容等,就是 authentication ,也就是之前 TokenProvider 的 getAuthentication() 塞的 UsernamePasswordAuthenticationToken(principal, token, authorities) , principal 就是使用者的資料, token 還有 authorities (權限)。

class JWTFilter(private val tokenProvider: TokenProvider) : GenericFilterBean() {

    @Throws(IOException::class, ServletException::class)
    override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) {
        val httpServletRequest = servletRequest as HttpServletRequest
        val jwt = resolveToken(httpServletRequest)
        if (!jwt.isNullOrBlank() && this.tokenProvider.validateToken(jwt)) {
            val authentication = this.tokenProvider.getAuthentication(jwt)
            SecurityContextHolder.getContext().authentication = authentication
        }
        filterChain.doFilter(servletRequest, servletResponse)
    }

    private fun resolveToken(request: HttpServletRequest): String? {
        val bearerToken = request.getHeader(AUTHORIZATION_HEADER)
        if (!bearerToken.isNullOrBlank() && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7)
        }
        return null
    }

    companion object {
        const val AUTHORIZATION_HEADER = "Authorization"
    }
}

使用者註冊

呼叫 UserService 的 registerUser() 註冊使用者

@RestController
@RequestMapping("/api/user")
class UserRestController(val service: UserService) {

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    fun registerAccount(@RequestBody user: UserDto): User {

        return service.registerUser(user, user.password!!)
    }

這裡 registerUser(),會把使用者密碼加密後,和其他資料,包含權限,一起寫入 db

@Service
class UserService(private val userRepository: UserRepository,
                  private val authorityRepository: AuthorityRepository,
                  private val passwordEncoder: PasswordEncoder
) : Logging {

    fun registerUser(user: UserDto, password: String): User {
        log().info("do registerUser: $user")
        val encryptedPassword = passwordEncoder.encode(password)
        val authorities = mutableSetOf<Authority>()
        authorityRepository.findById(USER).ifPresent { authorities.add(it) }

        val newUser = User(
                password = encryptedPassword,
                username = user.username,
                email = user.email?.toLowerCase(),
                createdBy = user.username,
                lastModifiedBy = user.username,
                authorities = user.authorities?.let { authorities ->
                    authorities.map { authorityRepository.findById(it) }
                            .filter { it.isPresent }
                            .mapTo(mutableSetOf()) { it.get() }
                } ?: mutableSetOf()
        )
        userRepository.save(newUser)
        log().info("Created Information for User: $newUser")
        return newUser
    }

認證 API

這裡就是用我們剛建立的方法,來做驗證,最後取得 token

@RestController
@RequestMapping("/api")
class UserJWTController(
    private val tokenProvider: TokenProvider,
    private val authenticationManagerBuilder: AuthenticationManagerBuilder
): Logging {
    @PostMapping("/authenticate")
    fun authorize( @RequestBody loginDto: LoginDto): ResponseEntity<JWTToken> {

        val authenticationToken = UsernamePasswordAuthenticationToken(loginDto.username, loginDto.password)
        val authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken)
        SecurityContextHolder.getContext().authentication = authentication
        val jwt = tokenProvider.createToken(authentication)
        log().info("jwt is:$jwt")

        val httpHeaders = HttpHeaders()
        httpHeaders.add(JWTFilter.AUTHORIZATION_HEADER, "Bearer $jwt")
        return ResponseEntity(JWTToken(jwt), httpHeaders, HttpStatus.OK)
    }

    /**
     * Object to return as body in JWT Authentication.
     */
    class JWTToken(@get:JsonProperty("id_token") var idToken: String?)
}

data class LoginDto(
        var username: String? = null,
        var password: String? = null
)

角色權限的存取限制

最後在原有的一些 API 上面 還有加上角色權限的存取限制

@PreAuthorize("hasAuthority(\"$ADMIN\")")
@PreAuthorize("hasAuthority(\"$USER\")")
@RestController
@RequestMapping("/api/user")
class UserRestController(val service: UserService) {

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    fun registerAccount(@RequestBody user: UserDto): User {

        return service.registerUser(user, user.password!!)
    }

    @GetMapping("/{username}")
    @PreAuthorize("hasAuthority(\"$ADMIN\")")
    fun findByUsername(@PathVariable username: String): ResponseEntity<User> =
            ResponseEntity.ok(service.findByUsername(username))

    @PutMapping()
    @PreAuthorize("hasAuthority(\"$ADMIN\")")
    fun saveUser(@RequestBody user: User): ResponseEntity<*> =
            ResponseEntity.ok(service.save(user));

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority(\"$ADMIN\")")
    fun deleteUser(@PathVariable id: Long): ResponseEntity<*> =
            ResponseEntity.ok(service.delete(id))

    @PostMapping("/query/email")
    @PreAuthorize("hasAuthority(\"$USER\")")
    fun findByEmail(@RequestBody request: UserRequest): ResponseEntity<*> =
            ResponseEntity.ok(service.findByEmailAndFilter(request.email))

}

最後來測試啦!

使用 USER 角色的 token

建立 USER 角色

https://ithelp.ithome.com.tw/upload/images/20201009/20129902OblmREvw2h.png

取得 tim 的 token

https://ithelp.ithome.com.tw/upload/images/20201009/20129902pFTqnz57ww.png

tim 角色是 USER 可以訪問 /query/email,因為是這樣設定的

@PostMapping("/query/email")
@PreAuthorize("hasAuthority(\"$USER\")")
fun findByEmail(@RequestBody request: UserRequest)

https://ithelp.ithome.com.tw/upload/images/20201009/201299027AgNkGtduY.png

但要做姓名查詢會被擋下,因為只允許 ADMIN 查,是這樣設定的

@GetMapping("/{username}")
@PreAuthorize("hasAuthority(\"$ADMIN\")")
fun findByUsername(@PathVariable username: String)

https://ithelp.ithome.com.tw/upload/images/20201009/20129902Xkp7ipykdu.png

使用 ADMIN 角色的 token

建立 ADMIN

jean 是 ADMIN

https://ithelp.ithome.com.tw/upload/images/20201009/201299020bNpySL0A5.png

取得 jean 的 token

https://ithelp.ithome.com.tw/upload/images/20201009/20129902wBZzNyk0vE.png

查詢成功!

https://ithelp.ithome.com.tw/upload/images/20201009/20129902c9efSYUmpx.png

以上就是今天的內容....終於完賽了,今天最後一天要寫這個有點硬阿...

最後再提醒一下,有新加權限的 table 和預設資料,有興趣的人可以去程式碼看看

全部程式碼都在這~

Reference

Angular Spring Boot JWT Authentication example
jhipster-kotlin


上一篇
[Day 29] Kotlin Journey - Kotlin + Spring Boot : TDD in CRUD
下一篇
[Day 31] Kotlin Journey - 完賽心得 & Jhipster-Kotlin
系列文
Kotlin 島深度之旅 30 天31

尚未有邦友留言

立即登入留言