[Day 30] 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 角色
取得 tim 的 token
tim 角色是 USER 可以訪問 /query/email,因為是這樣設定的
@PostMapping("/query/email")
@PreAuthorize("hasAuthority(\"$USER\")")
fun findByEmail(@RequestBody request: UserRequest)
但要做姓名查詢會被擋下,因為只允許 ADMIN 查,是這樣設定的
@GetMapping("/{username}")
@PreAuthorize("hasAuthority(\"$ADMIN\")")
fun findByUsername(@PathVariable username: String)
使用 ADMIN 角色的 token
建立 ADMIN
jean 是 ADMIN
取得 jean 的 token
查詢成功!
以上就是今天的內容....終於完賽了,今天最後一天要寫這個有點硬阿...
最後再提醒一下,有新加權限的 table 和預設資料,有興趣的人可以去程式碼看看
Reference
Angular Spring Boot JWT Authentication example jhipster-kotlin