[Spring Security] jwt를 활용하여 시큐리티 커스텀하기
- [ Backend ]/Spring
- 2022. 8. 13.
순수한 스프링 시큐리티에 jwt를 사용하여 클라이언트-서버 통신에 권한 인가(Authorization) 기능을 추가해보자. 먼저 스프링 시큐리티는 보안을 담당하는 프레임워크로, 세션 체크, auth redirect(로그인 완료시 다음화면 전환)과 같은 기능을 수행해준다.
의존성 추가
가장 먼저 jwt토큰을 만들어주는 라이브러리를 gradle에 추가해주자.
//시큐리티
implementation("org.springframework.boot:spring-boot-starter-security")
//jwt
// implementation("io.jsonwebtoken:jjwt:0.9.1")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")
io.jsonwebtoken::jjwt만 implement하게 되면 Filter쪽에서 클래스 import가 되질 않아서 밑에있는 3개로 바꾸었다.
역할 정의
다음으로 spring security 사용을 위해서는 role추가를 해주어야 한다. 일반 사용자와 관리자를 구분하기 위해 간단한 enum class를 추가하고, 엔티티에 추가해주었다.
enum class Role {
USER, ADMIN
}
//User.kt
..
@Enumerated(EnumType.STRING)
var role: Role? = null
..
UserDetails, UserDetailsService 커스텀
그리고 spring security에서 사용하는 UserDetails를 커스텀하여 확장 사용하기 위해서 UserDetatils를 상속받는 PrincipleDetails를 만들고, PrincipleDetailsService를 통해서 PrincipleDetails로 로그인 정보를 받아올 수 있게 했다.
(spring security에서 인증과 관련된 정보는 UserDetails를 상속받아서 관리한다)
class PrincipalDetails(private val user: User):UserDetails {
...
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
val collectors: MutableCollection<GrantedAuthority> = ArrayList()
collectors.add(GrantedAuthority {
user.role.toString()
})
return collectors
}
}
@Service
class PrincipalDetailsService:UserDetailsService {
@Autowired
private lateinit var userRepository:UserRepository
override fun loadUserByUsername(email: String?): UserDetails {
val principal= userRepository.findByEmail(email)
if(principal==null)
throw UserNotFoundException("해당 사용자를 찾을 수 없습니다")
return PrincipalDetails(principal)
}
}
JwtTokenProvider, JwtTokenFilter 구현
jwt 토큰을 이용한 인증을 위해서 필터를 구현해야 하는데, 그에 필요한 토큰 관련 메소드를 정의하는 JwtTokenProvider 클래스도 같이 정의한다.
class JwtTokenFilter(private var jwtTokenProvider: JwtTokenProvider):OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
var token = resolveToken(request)
if(jwtTokenProvider.validateToken(token)) { //토큰 유효성 확인
val authentication = jwtTokenProvider.getAuthentication(token) //권한 정보 확인(email, role)
SecurityContextHolder.getContext().authentication = authentication
}
filterChain.doFilter(request, response)
}
private fun resolveToken(request:HttpServletRequest): String {
var bearerToken = request.getHeader("Authorization")
if(bearerToken==null) {
println("JwtTokenFilter:bearerToken null")
throw EmptyHeaderAuthorizationException("Authorization 헤더에 토큰이 없습니다.")
}
return bearerToken
}
}
스프링 시큐리티 필터(UsernamePasswordAuthenticaionFilter)가 동작하여 Configuration에 등록해둔대로 JwtTokenFilter를 거치게 되면, Authorization 헤더의 토큰을 가져와서 토큰의 유효성을 확인하고 SecurityContextHolder에서 SecurityContext를 가져오고 권한 정보(email, role)와 함께 저장한다.
//국제 인터넷 표준화 기구(IETF)에서는 이를 방지하기 위해 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여,
//사용자가 한 번 Refresh Token으로 Access Token을 발급 받았으면, Refresh Token도 다시 발급 받도록 하는 것을 권장하고 있다.
@Component
class JwtTokenProvider(
@Value("\${jwt.secretkey}")
val SECRET_KEY:String,
@Value("\${jwt.expirelen}")
val VALID_TIME:Int
) {
//키값 얻기
val keyBytes = Decoders.BASE64.decode(SECRET_KEY)
val key = Keys.hmacShaKeyFor(keyBytes)
//jwt토큰을 복호화하여 토큰의 정보를 꺼내기
fun getAuthentication(accessToken:String): UsernamePasswordAuthenticationToken {
var claims = parseClaims(accessToken)
if(claims.get("email")==null || claims.get("ROLE_")==null)
throw InvalidTokenException("Invalid JWT Token (Cannot get Email|Role)")
// 클레임에서 권한 정보(role) 가져오기
val authorities: Collection<GrantedAuthority?> =
Arrays.stream(claims.get("ROLE_").toString().split(",".toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray())
.map { role: String? -> SimpleGrantedAuthority(role) }
.collect(Collectors.toList())
// User객체를 만들어서 Authentication 리턴
// var principal = PrincipalDetails(yourssu.blog.entity.User(claims.get("email").toString(), "", claims.get("role").toString()))
var user = User(claims.get("email") as String, "", authorities)
return UsernamePasswordAuthenticationToken(user, "", user.authorities)
}
//토큰 정보 검증
fun validateToken(accessToken: String): Boolean {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken)
return true
} catch (e:SecurityException) {
throw InvalidTokenException("Invalid JWT Token")
} catch (e:MalformedJwtException) {
throw InvalidTokenException("Invalid JWT Token (Malformed)")
} catch (e:ExpiredJwtException) {
throw InvalidTokenException("Expired JWT Token (Expired)")
} catch (e:UnsupportedJwtException) {
throw InvalidTokenException("Unsupported JWT Token")
} catch (e:IllegalArgumentException) {
throw InvalidTokenException("Jwt String is Empty")
}
}
//유저 정보 기반으로 accessToken, refreshToken 생성
fun generateToken(email:String, role:Role): TokenInfo {
//권한 가져오기
// var authorities = authentication.authorities.stream()
// .map(GrantedAuthority::getAuthority)
// .collect(Collectors.joining(","))
var now = Date().time
var expireTime = Date(now+VALID_TIME)
//accessToken 발급
var accessToken = Jwts.builder()
// .setSubject(authentication.name)
.claim("email", email)
.claim("ROLE_", role)
.setExpiration(expireTime)
.signWith(key, SignatureAlgorithm.HS256)
.compact()
//refreshToken 발급 (accessToken의 검증)
var refreshToken = Jwts.builder()
// .setSubject(authentication.name)
.claim("email", email)
.claim("ROLE_", role)
.setExpiration(Date(now+VALID_TIME*10))
.signWith(key, SignatureAlgorithm.HS256)
.compact()
println(accessToken)
return TokenInfo(
grantType = "Bearer",
accessToken = accessToken,
refreshToken = refreshToken
)
}
private fun parseClaims(accessToken: String): Claims {
return try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).body
} catch (e: ExpiredJwtException) {
e.claims
}
}
}
jwt 활용+
토큰을 확인하거나, 발급하여 시큐리티에 위임하는 로직을 만들었다. 이제 이 토큰 관련 함수들을 중심으로 회원가입, 로그인시에 활용할 수 있다. 그런데 jwt를 제대로 사용하기 위해서는 회원가입/로그인에서 토큰을 발급, 확인하는 것 뿐 아니라 다른 api들에 접근할 때에도 회원 정보가 아닌 token을 이용하여 response를 보낼 수 있어야 한다.
예를 들어, 이전 요청에서는 사용자 정보를 알기 위해서 request body에 email, password와 같은 요청정보를 넣어 보냈다면, 이제는 필터에서 확인한 토큰의 정보를 가져와서 회원정보를 알 수 있어야 한다.
그러기 위해서는 크게 @AuthenticationPrincipal 어노테이션을 이용하여 인증이 필요한 요청마다 SecurityContextHolder에 저장한 인증값을 가져오는 방법과, HandlerMethodArgumentResolver를 이용하여 토큰에서 인증값을 가져오는 클래스를 만드는 방법이 있다.
'[ Backend ] > Spring' 카테고리의 다른 글
[Spring] 스프링과 Tomcat (0) | 2023.02.16 |
---|---|
[Spring Security] @AuthenticationPrincipal, HandlerMethodArgumentResolver (0) | 2022.11.13 |
[Spring Security] JWT(Json Web Token) 개념 (0) | 2022.08.12 |
[Spring Security] UserDetails, UserDetailsService (0) | 2022.08.10 |
[Spring Security] 스프링 시큐리티 구조, 시큐리티 필터 (0) | 2022.07.21 |