JWT(Json Web Token)란 JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token이다. 즉, 토큰의 한 종류라고 생각하면 된다.
일반적으로 쿠키 저장소를 사용하여 JWT를 저장한다.
로그인 정보를 Server 에 저장하지 않고, Client 에 로그인 정보를 JWT 로 암호화하여 저장 → 모든 요청에 JWT를 헤더에 담아 보내 유효성 검증
기존 세션 방식에선 서버 데이터 베이스에 정보를 저장하여야 했지만 JWT방식에서 서버는 Secret 만 보유하고 있으면 됨.
동시 접속자가 많을 때 서버 측 부하 낮춤 (기존 세션시엔 디비조회가 필수였지만 토큰 검증만 하면 되니)
Client, Sever 가 다른 도메인을 사용할 때
예) 카카오 OAuth2 로그인 시 JWT Token 사용
MSA 방식이 인기를 얻으며 덩달아 인기도가 높아짐
구현의 복잡도 증가
JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
이미 생성된 JWT 를 일부만 만료시킬 방법이 없음 -> 블랙리스트 방식으로 해결이 가능하지만 이렇게 되면 유출된 토큰을 디비에 저장하여야 해서 세션에 비해 장점이 사라짐.
Secret key 유출 시 JWT 조작 가능
JWT 토큰이 유출시 다른사람이 사용가능 -> 그래서 Access/Refresh Token 개념이 등장.
AccessToken : 유저가 API 요청할때 사용됨
만료기간을 짧게 설정해 유출되더라도 공격자가 오래 사용 못하도록 설정
RefreshToken : 만료된 AccessToken을 재발급하는데만 사용함
만료시간이 길고 어세스 토큰 발급할때만 사용하며 Body를 통하여 전달하여 유출위험이 낮음
JWT 는 누구나 평문으로 복호화 가능
하지만 Secret Key 가 없으면 JWT 수정 불가능
→ 결국 JWT 는 Read only 데이터
이렇게 Hearder, Payload, Signature 3가지 요소로 구성됨
Payload에 실제 유저의 정보가 들어있고, HEADER와 VERIFY SIGNATURE부분은 암호화 관련된 정보 양식
Hearder, Payload가 같아도 Signature가 다르면 다른 토큰
사용자가 서버로 로그인을 하면 Access/RefreshToken 이 발급된다
그 토큰을 바탕으로 사용자는 어떤 행위(API요청)시 마다 토큰과 같이 요청을 하면
서버가 토큰을 바탕으로 사용자를 인증하고 알맞은 응답을 반환해준다.
전체적인 흐름만 설명할 예정이라 자세한 내용은 깃허브 코드 참조 바랍니다.
[사용하실때 스타 한번식만 부탁드려요 ^^^^^]
(코드복사, 포크, 다운 모두 가능!)
dependencies {
// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
implementation("io.jsonwebtoken:jjwt-impl:0.12.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.12.5")
}
@Entity
class UserEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null, // ID (PK)
@Column(nullable = false)
val email: String, // Email
@Column(nullable = false)
val name: String,
@Column(nullable = false)
val password: String, // Password
@Column(nullable = false)
val role: UserRoles = UserRoles.ROLE_USER
)
일단 예시니 간단하게 id, userId, password를 포함하는 UserEntity를 만들었다.
data class User(
val id: Long? = null,
val email: String = "",
val name: String = "",
val password: String = "",
val role: UserRoles = UserRoles.ROLE_USER
)
UserModel 도 만들어주고
interface UserRepository : JpaRepository<UserEntity, Long> {
fun findByEmail(email: String): UserEntity?
fun existsByEmail(email: String): Boolean
}
UserRepository도 만들어준다 (간단구현을 목적으로하여 JpaRepository를 사용하였다)
@RestController
@RequestMapping("/user")
class UserController(
private val userService: UserService
) {
@PostMapping("/register")
fun registerUser(@RequestBody registerUserRequest: RegisterUserRequest): BaseResponse<Unit> {
return userService.registerUser(registerUserRequest)
}
@PostMapping("/login")
fun loginUser(@RequestBody loginRequest: LoginRequest): BaseResponse<JwtInfo> {
return userService.loginUser(loginRequest)
}
@PostMapping("/refresh")
fun refreshUser(@RequestBody refreshRequest: RefreshRequest): BaseResponse<String> {
return userService.refreshToken(refreshRequest)
}
}
회원가입 로그인 리프레쉬만 구현할 예정이라 컨트롤러를 이렇게 만들었다.
@Service
class UserServiceImpl(
private val userRepository: UserRepository,
private val userMapper: UserMapper,
private val bytePasswordEncoder: BCryptPasswordEncoder,
private val jwtUtils: JwtUtils
) : UserService {
@Transactional
override fun registerUser(registerUserRequest: RegisterUserRequest): BaseResponse<Unit> {
if(userRepository.existsByEmail(registerUserRequest.email)) throw CustomException(UserErrorCode.USER_ALREADY_EXIST)
userRepository.save(
userMapper.toEntity(
userMapper.toDomain(registerUserRequest, bytePasswordEncoder.encode(registerUserRequest.password.trim()))
)
)
return BaseResponse(
message = "회원가입 성공"
)
}
@Transactional(readOnly = true)
override fun loginUser(loginRequest: LoginRequest): BaseResponse<JwtInfo> {
val user = userRepository.findByEmail(loginRequest.email)?: throw CustomException(UserErrorCode.USER_NOT_FOUND)
if (!bytePasswordEncoder.matches(loginRequest.password, user.password)) throw CustomException(UserErrorCode.USER_NOT_MATCH)
return BaseResponse(
message = "로그인 성공",
data = jwtUtils.generate(
user = userMapper.toDomain(user)
)
)
}
@Transactional(readOnly = true)
override fun refreshToken(refreshRequest: RefreshRequest): BaseResponse<String> {
val token = jwtUtils.getToken(refreshRequest.refreshToken)
if (jwtUtils.checkTokenInfo(token) == JwtErrorType.ExpiredJwtException) {
throw CustomException(JwtErrorCode.JWT_TOKEN_EXPIRED)
}
val user = userRepository.findByEmail(
jwtUtils.getUsername(token)
)
return BaseResponse (
message = "리프레시 성공 !",
data = jwtUtils.refreshToken(
user = userMapper.toDomain(user!!)
)
)
}
}
서비스에서도 딱히 설명할 코드가 없다.
회원가입시 이미 이메일이 존재하는지 확인하고 있으면 오류 없으면 회원가입 완료시킨다.
로그인시엔 이메일을 바탕으로 데이터를 찾고 비밀번호를 비교한 뒤 비밀번호가 맞으면 JWT반환 안맞을시 오류를 반환한다.
리프레쉬 요청시 토큰을 검증하고 옮다면 새 AccessToken을 반환한다.
@Configuration
@EnableWebSecurity
class SecurityConfig (
private val jwtUtils: JwtUtils,
private val objectMapper: ObjectMapper
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http
.cors {
corsConfigurationSource()
}
.csrf {
it.disable()
}
.formLogin {
it.disable()
}
.sessionManagement { session ->
session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
}
.authorizeHttpRequests {
it
.requestMatchers("/user/**",).permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(JwtAuthenticationFilter(jwtUtils, objectMapper), UsernamePasswordAuthenticationFilter::class.java)
.exceptionHandling {
it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.NOT_FOUND))
}
.build()
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val corsConfiguration = CorsConfiguration()
corsConfiguration.addAllowedOriginPattern("*")
corsConfiguration.addAllowedHeader("*")
corsConfiguration.addAllowedMethod("*")
corsConfiguration.allowCredentials = true
val urlBasedCorsConfigurationSource = UrlBasedCorsConfigurationSource()
urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration)
return urlBasedCorsConfigurationSource
}
}
cors를 방지하기 위하여 corsConfigurationSource를 통하여 cors를 해결하였고, csrf, Security formLogin (기본로그인), sessionManagement를 모두 disable 하였다.
authorizeHttpRequests에서 리퀘스트시 어느 부분에서 권한체크를 할지 어느부분에서 권한체크를 풀지 결정할 수 있다.
그리고 addFilterBefore를 통하여 요청시 앞에 JwtAuthenticationFilter로 권한체크를 진행한다.
class JwtAuthenticationFilter(
private val jwtUtils: JwtUtils,
private val objectMapper: ObjectMapper
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val token: String? = request.getHeader("Authorization")
val path: String = request.servletPath
if (path.startsWith("/user/")
) {
filterChain.doFilter(request, response)
return
}
if (token.isNullOrEmpty() || !token.startsWith("Bearer ")) {
setErrorResponse(response, JwtErrorCode.JWT_EMPTY_EXCEPTION)
} else {
when (jwtUtils.checkTokenInfo(jwtUtils.getToken(token))) {
JwtErrorType.OK -> {
SecurityContextHolder.getContext().authentication = jwtUtils.getAuthentication(token)
doFilter(request, response, filterChain)
}
JwtErrorType.ExpiredJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_EXPIRED)
JwtErrorType.SignatureException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR)
JwtErrorType.MalformedJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_ERROR)
JwtErrorType.UnsupportedJwtException -> setErrorResponse(
response,
JwtErrorCode.JWT_TOKEN_UNSUPPORTED_ERROR
)
JwtErrorType.IllegalArgumentException -> setErrorResponse(
response,
JwtErrorCode.JWT_TOKEN_ILL_EXCEPTION
)
JwtErrorType.UNKNOWN_EXCEPTION -> setErrorResponse(response, JwtErrorCode.JWT_UNKNOWN_EXCEPTION)
}
}
}
private fun setErrorResponse(
response: HttpServletResponse,
errorCode: JwtErrorCode
) {
response.status = errorCode.status.value()
response.contentType = "application/json;charset=UTF-8"
response.writer.write(
objectMapper.writeValueAsString(
BaseResponse<String>(
status = errorCode.status.value(),
state = errorCode.state,
message = errorCode.message
)
)
)
}
}
request에서 해더를 가져와 분석한다. 만약 요청주소가 앞에서 권한인증이 필요없는 부분은
if (path.startsWith("/user/")
) {
filterChain.doFilter(request, response)
return
}
이렇게 해서 아래 코드로 넘어가지 않도록 만들어주자.
if (token.isNullOrEmpty() || !token.startsWith("Bearer ")) {
setErrorResponse(response, JwtErrorCode.JWT_EMPTY_EXCEPTION)
} else {
when (jwtUtils.checkTokenInfo(jwtUtils.getToken(token))) {
JwtErrorType.OK -> {
SecurityContextHolder.getContext().authentication = jwtUtils.getAuthentication(token)
doFilter(request, response, filterChain)
}
JwtErrorType.ExpiredJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_EXPIRED)
JwtErrorType.SignatureException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR)
JwtErrorType.MalformedJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_ERROR)
JwtErrorType.UnsupportedJwtException -> setErrorResponse(
response,
JwtErrorCode.JWT_TOKEN_UNSUPPORTED_ERROR
)
JwtErrorType.IllegalArgumentException -> setErrorResponse(
response,
JwtErrorCode.JWT_TOKEN_ILL_EXCEPTION
)
JwtErrorType.UNKNOWN_EXCEPTION -> setErrorResponse(response, JwtErrorCode.JWT_UNKNOWN_EXCEPTION)
}
}
}
}
토큰이 nullOrEmpty 이거나 Bearer로 시작하지 않으면 오류 반환
jwtUtils을 통하여 또 오류가 발생하면 오류를 반환한다
오류는
private fun setErrorResponse(
response: HttpServletResponse,
errorCode: JwtErrorCode
) {
response.status = errorCode.status.value()
response.contentType = "application/json;charset=UTF-8"
response.writer.write(
objectMapper.writeValueAsString(
BaseResponse<String>(
status = errorCode.status.value(),
state = errorCode.state,
message = errorCode.message
)
)
)
}
을 통해서 설정해 클라이언트한테 json으로 반환한다.
jwt는 CustomException을 통하여 클라이언트한테 값이 전달이 안된다.
다음과 같이 직접 반환값을 만들어 오류를 반환해줘야 클라이언트한테 값이 전달된다.
@Component
class JwtUtils(
private val jwtProperties: JwtProperties,
private val userDetailsService: UserDetailsService
) {
private val secretKey: SecretKey = SecretKeySpec(
this.jwtProperties.secretKey.toByteArray(StandardCharsets.UTF_8),
Jwts.SIG.HS256.key().build().algorithm
)
fun getUsername(token: String): String {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).payload.get(
"email",
String::class.java
)
}
fun checkTokenInfo(token: String): JwtErrorType {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token)
return JwtErrorType.OK
} catch (e: ExpiredJwtException) {
return JwtErrorType.ExpiredJwtException
} catch (e: SignatureException) {
return JwtErrorType.SignatureException
} catch (e: MalformedJwtException) {
return JwtErrorType.MalformedJwtException
} catch (e: UnsupportedJwtException) {
return JwtErrorType.UnsupportedJwtException
} catch (e: IllegalArgumentException) {
return JwtErrorType.IllegalArgumentException
} catch (e: Exception) {
return JwtErrorType.UNKNOWN_EXCEPTION
}
}
fun getToken(token: String): String {
return token.removePrefix("Bearer ")
}
fun generate(member: Member): JwtInfo {
val accessToken = createToken(
member = member,
tokenExpired = jwtProperties.accessExpired
)
val refreshToken = createToken(
member = member,
tokenExpired = jwtProperties.refreshExpired
)
return JwtInfo("Bearer $accessToken", "Bearer $refreshToken")
}
fun getAuthentication(token: String): Authentication {
val userDetails: UserDetails = userDetailsService.loadUserByUsername(getUsername(getToken(token)))
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
}
fun refreshToken(member: Member): String {
return "Bearer " + createToken(
member = member,
tokenExpired = jwtProperties.accessExpired
)
}
private fun createToken(member: Member, tokenExpired: Long): String {
val now: Long = Date().time
return Jwts.builder()
.claim("id", member.id?.value)
.claim("email", member.email.value)
.claim("role", member.role.value)
.issuedAt(Date(now))
.expiration(Date(now + tokenExpired))
.signWith(secretKey)
.compact()
}
}
코드를 아래서에서 하나씩 설명하자면
토큰을 바탕으로 유저 이메일을 가져온다.
fun getUsername(token: String): String {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).payload.get(
"email",
String::class.java
)
}
토큰을 바탕으로 토큰 상태를 판단하고 에러코드를 반환한다.
fun checkTokenInfo(token: String): JwtErrorType {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token)
return JwtErrorType.OK
} catch (e: ExpiredJwtException) {
return JwtErrorType.ExpiredJwtException
} catch (e: SignatureException) {
return JwtErrorType.SignatureException
} catch (e: MalformedJwtException) {
return JwtErrorType.MalformedJwtException
} catch (e: UnsupportedJwtException) {
return JwtErrorType.UnsupportedJwtException
} catch (e: IllegalArgumentException) {
return JwtErrorType.IllegalArgumentException
} catch (e: Exception) {
return JwtErrorType.UNKNOWN_EXCEPTION
}
}
토큰 앞단에 Bearer 를 제거한 문자열을 반환한다.
fun getToken(token: String): String {
return token.removePrefix("Bearer ")
}
맴버 정보를 바탕으로 AccessToken과 RefreshToken을 발급한다.
fun generate(member: Member): JwtInfo {
val accessToken = createToken(
member = member,
tokenExpired = jwtProperties.accessExpired
)
val refreshToken = createToken(
member = member,
tokenExpired = jwtProperties.refreshExpired
)
return JwtInfo("Bearer $accessToken", "Bearer $refreshToken")
}
토큰을 가지고 사용자의 이름을 찾고, 사용자 세부 정보를 로드하여 인증 토큰을 생성하고 반환하는 역할한다.
fun getAuthentication(token: String): Authentication {
val userDetails: UserDetails = userDetailsService.loadUserByUsername(getUsername(getToken(token)))
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
}
토큰을 바탕으로 리프레쉬 토큰을 생성한다.
fun refreshToken(member: Member): String {
return "Bearer " + createToken(
member = member,
tokenExpired = jwtProperties.accessExpired
)
}
토큰을 생성하는 메서드다
private fun createToken(member: Member, tokenExpired: Long): String {
val now: Long = Date().time
return Jwts.builder()
.claim("id", member.id?.value)
.claim("email", member.email.value)
.claim("role", member.role.value)
.issuedAt(Date(now))
.expiration(Date(now + tokenExpired))
.signWith(secretKey)
.compact()
}
UserDetails를 상속받아 아래서 재정의를 한다.
class JwtUserDetails(
val user: User
) : UserDetails {
val id: Long? = user.id
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
val authorities: MutableCollection<GrantedAuthority> = ArrayList()
authorities.add(SimpleGrantedAuthority(user.role.name))
return authorities
}
override fun getPassword(): String {
return user.password
}
override fun getUsername(): String {
return user.name
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
이건 내가 만든 커스텀 어노테이션인데 컨트롤러에서 사용시 요청한 유저 id를 반환하도록 만들었다
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? -1L : #this.id")
// if this == anonymousUser return -1 else return userId
annotation class GetAuthenticatedId
PK = 1인 유저가 헤더에 JWT를 넣고 요청하면
@RestController
class TestController {
@PostMapping("/")
fun test(
@GetAuthenticatedId userId: Long
){
println("======================")
println("userId = $userId")
println("======================")
}
}
======================
userId = 1
======================
가 출력된다.
자 이제 어디가서 나 회원가입/로그인 할 수 있다 하고 다니자고요!!!!!!!
장난이고 더 공부하세요
Template을 58000% 활용하는 방법!
https://velog.io/@yeseong0412/바퀴를-다시-만들-필요가-있나요
실습까지 정리해주셔서 이해하기 쉽네요. 글 잘 보고 갑니다!!