일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- offsetdatetime
- CodePipeline
- PAGING
- mysql
- centos7
- producer
- cd
- Streams
- Kubernetes
- kafka
- consumer
- ECS
- transactionaleventlistener
- Spring JPA
- QueryDSL
- CI
- git
- JPA
- spring
- topic생성
- bean
- Entity
- K8s
- API
- entity graph
- Kotlin
- Spring Data JPA
- AWS
- spring kafka
- mirror maker2
- Today
- Total
Yebali
Spring Security + JWT 발급 본문
요즘엔 다양한 방식의 인증/인가 시스템이 존재합니다
AWS Cognito나 Firebase Authentication 등 클라우드 서비스도 있고,
Keycloak 같은 IAM(Identity and Access Management solution)도 존재합니다.
이렇게 다양하고 좋은 서비스들이 있지만 오늘은 가장 기본적인(?)
Spring Security으로 JWT을 발급해 주는 인증/인가 서비스를 만들어보려고 합니다.
아래 내용은 Spring Security를 사용해 회원을 관리하고,
로그인에 성공하면 해당 회원의 권한이 인가된 JWT를 발급하고,
발급된 JWT를 사용해 API를 호출하는 예시입니다.
의존성 추가
// gradle.build.kts
dependencies {
// Spring Security
implementation("org.springframework.boot:spring-boot-starter-security")
// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
implementation("io.jsonwebtoken:jjwt-impl:0.12.6")
implementation("io.jsonwebtoken:jjwt-jackson:0.12.6")
}
먼저 의존성으로 Spring Security와 jsonwebtoken 의존성을 추가합니다.
Spring Security은 당연히 추가해야하고 jsonwebtoken은 JWT를 만들기 위한 기능을 위해 필요합니다.
회원 관련 기능 추가
회원 Entity 추가
@Entity
@Table(
indexes = [
Index(columnList = "username", unique = true),
],
)
class Member(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val username: String,
val password: String,
val nickname: String,
@ElementCollection(fetch = FetchType.EAGER)
val roles: List<String>,
)
회원과 관련된 정보를 저장할 Entity입니다.
해당 엔티티에 데이터를 기반으로 Spring security 기능을 사용해 인증/인가 기능을 구현할 예정입니다.
회원의 권한을 값을 가지는 roles은 간단한 예시라 List<String>으로 구현했습니다.
PasswordEncoder Bean 추가
@Configuration
class PasswordEncoderConfig {
@Bean
fun passwordEncoder(): PasswordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
회원의 Password를 암호화 할 때 사용할 PasswordEncoder를 Bean으로 등록합니다.
기본적으로 제공해주는 DelegatingPasswordEncoder를 사용했습니다.
UserDetailsService 구현
@Service
@Transactional(readOnly = true)
class UserDetailsServiceImpl(
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder,
) : UserDetailsService {
// Spring Security가 AuthenticationToken을 인증할 때 호출한다.
override fun loadUserByUsername(username: String): UserDetails {
return memberRepository.findByUsername(username)
?.let { createUserDetails(it) }
?: throw IllegalArgumentException("User not found")
}
// UserDetails의 password는 암호화된 값이어야 한다.
private fun createUserDetails(member: Member): UserDetails {
return User.builder()
.username(member.username)
.password(passwordEncoder.encode(member.password))
.roles(*member.roles.toTypedArray())
.build()
}
}
UserDetailsService는 Spring security가 회원을 인증할 때, 회원 정보를 조회하는 역할을 합니다.
추후 username, password으로 만든 authenticationToken을 사용해 회원을 인증할 때, 오버라이드한 loadUserByUsername 매서드가 호출됩니다.
UserDetails의 password값은 평문을 사용할 수 없고 항상 암호화되어있어야 하기 때문에
PasswordEncoder를 사용해 암호화합니다.
회원 Controller & Service 구현
@RestController
@RequestMapping("/member")
class MemberController(
private val memberService: MemberService,
) {
@PostMapping("/sign-up")
fun signUp(
@RequestBody request: SignUpRest.Request,
): SignUpRest.Response {
val result = memberService.signUp(
SignUp.Command(
username = request.username,
password = request.password,
nickname = request.nickname,
),
)
return SignUpRest.Response(
username = result.username,
nickname = result.nickname,
)
}
@PostMapping("/sign-in")
fun signIn(
@RequestBody request: SignInRest.Request,
): SignInRest.Response {
val result = memberService.signIn(
SignIn.Command(
username = request.username,
password = request.password,
),
)
return SignInRest.Response(
accessToken = result.jwt.accessToken,
refreshToken = result.jwt.refreshToken,
)
}
}
@Service
@Transactional
class MemberService(
private val memberRepository: MemberRepository,
private val jwtService: JwtService,
) {
fun signUp(command: SignUp.Command): SignUp.Result {
val member = memberRepository.save(
Member(
username = command.username,
password = command.password,
nickname = command.nickname,
roles = listOf("USER"),
),
)
return SignUp.Result(
username = member.username,
nickname = member.nickname,
)
}
fun signIn(command: SignIn.Command): SignIn.Result {
val jwt = jwtService.issueJwt(
IssueJwt.Command(
username = command.username,
password = command.password,
),
)
return SignIn.Result(jwt = jwt)
}
}
회원가입과 로그인을 담당하는 코드입니다.
회원가입은 단순히 입력받은 회원의 정보를 Member 엔티티에 저장하는 하고,
로그인은 JWT형태의 accessToken과 refreshToken을 발급하는 기능을 합니다.
JWT 관련 기능 추가
JWT DTO와 Claim 정의
data class Jwt(
val accessToken: String,
val refreshToken: String,
)
enum class JwtClaim(val value: String) {
USERNAME("username"),
AUTH("auth"),
NICKNAME("nickname"),
}
코드 내에서 JWT 데이터를 담을 'Jwt' DTO와 Claim을 정의합니다.
Claim이란 사용자에 대한 프로퍼티나 속성을 말합니다. JWT는 Claim을 key로 사용하는 MAP의 형태로 사용자에 대한 정보를 전달합니다.
Refresh Token Entity 추가
/**
* RefreshToken으로 AccessToken을 발행하기 위한 토큰 저장 테이블.
* Redis에 만료시간 넣어서 저장하는게 더 좋아보임.
* */
@Entity
@Table(indexes = [Index(name = "idx_refresh_token", columnList = "refreshToken")])
class RefreshToken(
@Id
val id: UUID = UUID.randomUUID(),
val refreshToken: String,
val username: String,
)
JWT을 발급할땐 보통 refresh token을 함께 발급합니다.
refresh token은 기존에 발급된 access token을 갱신하기 위해 사용되는데 서버에서는 발급한 refresh token을 저장하고 있다가
갱신 요청이 들어오면 어떤 사용자의 access token을 새로 발급해야하는지 알기 위해 'refreshToken'과 'username'을 저장했습니다.
예시에서는 간단한 구현을 위해 토큰 만료시간을 고려하지 않았는데
실제 운영할 코드를 작업한다면 Redis에 만료시간을 주고 데이터를 저장하는 방식으로 구현하는게 좋아보입니다.
Jwt Controller & Service 구현
@RestController
class TokenController(
private val jwtService: JwtService,
) {
@PostMapping("/refresh-token")
fun refreshToken(
@RequestBody request: RefreshTokenRest.Request,
): RefreshTokenRest.Response {
val jwtToken = jwtService.renewJwt(request.refreshToken)
return RefreshTokenRest.Response(
accessToken = jwtToken.accessToken,
refreshToken = jwtToken.refreshToken,
)
}
}
refreshToken값을 받아 JWT을 갱신해주는 API입니다.
@Service
@Transactional
class JwtService(
@Value("\${jwt.access-secret}")
private val accessSecret: String,
@Value("\${jwt.refresh-secret}")
private val refreshSecret: String,
private val refreshTokenRepository: RefreshTokenRepository,
private val memberRepository: MemberRepository,
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
) {
private val accessSecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(accessSecret))
private val refreshSecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(refreshSecret))
// 토큰 발급
fun issueJwt(command: IssueJwt.Command): Jwt {
val member = memberRepository.findByUsername(command.username)
?: throw IllegalArgumentException("Member not found")
val authenticationToken = UsernamePasswordAuthenticationToken(command.username, command.password)
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
val now = System.currentTimeMillis()
val accessToken = Jwts
.builder()
.subject(authentication.name)
.claims(
mapOf(
JwtClaim.USERNAME.value to member.username,
JwtClaim.NICKNAME.value to member.nickname,
JwtClaim.AUTH.value to authentication.authorities.joinToString { it.authority },
),
)
.expiration(Date(now + ACCESS_TOKEN_EXPIRATION))
.signWith(accessSecretKey)
.compact()
val refreshToken = Jwts
.builder()
.expiration(Date(now + REFRESH_TOKEN_EXPIRATION))
.signWith(refreshSecretKey)
.compact()
// RefreshToken 저장
refreshTokenRepository.save(
RefreshToken(refreshToken = refreshToken, username = member.username),
)
return Jwt(
accessToken = "Bearer $accessToken",
refreshToken = refreshToken,
)
}
// 토큰 갱신
fun renewJwt(refreshToken: String): Jwt {
val refreshTokenEntity = refreshTokenRepository.findByRefreshToken(refreshToken)
?: throw IllegalArgumentException("Refresh token is not valid")
val member = memberRepository.findByUsername(refreshTokenEntity.username)
?: throw IllegalArgumentException("Member not found")
refreshTokenRepository.delete(refreshTokenEntity)
return issueJwt(
IssueJwt.Command(
username = member.username,
password = member.password,
),
)
}
fun getAuthentication(accessToken: String): Authentication {
val claims = parseClaims(accessToken)
val username = claims[JwtClaim.USERNAME.value].toString()
val authorities = claims[JwtClaim.AUTH.value]
.toString()
.split(",")
.map { SimpleGrantedAuthority(it) }
return UsernamePasswordAuthenticationToken(
User(username, "", authorities),
null,
authorities,
)
}
private fun parseClaims(accessToken: String): Claims {
return Jwts.parser()
.verifyWith(accessSecretKey)
.build()
.parseSignedClaims(accessToken)
.payload
}
companion object {
private const val ACCESS_TOKEN_EXPIRATION = 86400000L
private const val REFRESH_TOKEN_EXPIRATION = 86400000L
}
}
JWT을 발급, 갱신 등에 관한 역할을 하는 @Service 입니다.
issueJwt() 매서드는 파라미터에서 받은 username, password값을 가지고 UserPasswordAuthenticationToken을 만들어
authenticationManager를 사용해 사용자를 인증합니다.
사용자가 인증되었으면 io.jsonwebtoken.Jwts 을 사용해 accessToken과 refreshToken을 만들고 Jwt객체에 담아 반환합니다.
그리고 추후 refreshToken으로 accessToken을 갱신하기 위해 refreshToken을 DB에 저장합니다.
renewJwt() 매서드는 파라미터로 받은 refreshToken정보를 바탕으로 member를 조회하여
issueJwt() 매서드를 사용해 새로운 토큰을 만들어 반환합니다.
getAuthetication() 매서드는 accessToken으로부터 사용자의 인가 정보를 꺼내 Authentication객체에 담아 반환합니다.
parseClaims() 매서드는 accessToken의 유효성을 검사하고 토큰 내부의 Claim 값들을 반환합니다.
Claims Interface는 Map을 상속받고 있기 때문에 claims[key] 형태로 간단하게 꺼내 사용할 수 있습니다.
expiration값은 임의로 24시간으로 했습니다.
JwtAuthenticationFilter 및 WebSecurityConfig 추가
class JwtAuthenticationFilter(
private val jwtService: JwtService,
private val excludePaths: List<String>,
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
if (excludePaths.any { request.servletPath == it }) {
filterChain.doFilter(request, response)
return
}
val token = request.extractToken()
val authentication = jwtService.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication
filterChain.doFilter(request, response)
}
private fun HttpServletRequest.extractToken(): String {
val token = this.getHeader("Authorization")
if (token == null || token.isBlank()) {
throw IllegalArgumentException("Token is missing")
}
return if (token.startsWith("Bearer ")) {
token.substring(7)
} else {
throw IllegalArgumentException("Token is not Bearer Token")
}
}
}
API 요청을 받아 처리하기 전에 Header에 포함된 accessToken을 검증하는 필터입니다.
회원가입, 로그인 등과 같이 인증이 필요없는 경로는 검증하지 않기 위해 excludePaths를 파라미터로 받고있습니다.
그 외 일반적인 요청은 토큰을 추출해 jwtService.getAuthentication()을 사용해 인증 정보를 받아 SecurityContext에 넣습니다.
@Configuration
@EnableWebSecurity
class WebSecurityConfig(
private val jwtService: JwtService,
) {
@Bean
fun filterChain(httpSecurity: HttpSecurity): SecurityFilterChain =
httpSecurity
.httpBasic { it.disable() }
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests {
it
.requestMatchers(*EXCLUDE_PATH.toTypedArray()).permitAll()
.requestMatchers("/member/test").hasRole("USER")
.requestMatchers("/admin/test").hasRole("ADMIN")
.anyRequest().permitAll()
}.addFilterBefore(
JwtAuthenticationFilter(jwtService = jwtService, excludePaths = EXCLUDE_PATH),
UsernamePasswordAuthenticationFilter::class.java,
)
.build()
companion object {
// 회원가입, 로그인, 토큰 갱신은 토큰 검증을 하지 않는다.
private val EXCLUDE_PATH = listOf("/member/sign-up", "/member/sign-in", "/refresh-token")
}
}
filterChain을 생성해줍니다.
예제는 토큰기반 인증이기 때문에 httpBasic와 csrf을 비활성화하고, sessionManager의 정책을 STATELESS으로 설정합니다.
authorizeHttpRequests에서 회원가입, 로그인, 토큰 갱신은 권한없이 실행할 수 있도록 .permitAll() 해주고
테스트를 위해 '/member/test', '/admin/test' 경로는 각각 USER와 ADMIN역할을 가진 사용자만 접근할 수 있도록 설정합니다.
Spring에서 Filter는 hadler에 매핑하기 전에 실행되는 로직이라 '/admin/test' API를 생성하지 않아도 괜찮습니다. (예시니까요...)
테스트
검증은 테스트 코드를 통해 진행했습니다.
Refresh Token으로 Access Token 발급
@SpringBootTest
@ActiveProfiles("test")
class JwtServiceTest {
@Autowired
private lateinit var jwtService: JwtService
@Autowired
private lateinit var memberService: MemberService
@Test
fun `Refresh token으로 새로운 Access token 재발급`() {
signUp("yebali", "1234", "예발이")
val originalJwt = jwtService.issueJwt(
command = IssueJwt.Command(username = "yebali", password = "1234"),
)
val renewedJwt = assertDoesNotThrow {
// 같은 시간에 토큰이 발급되면 같은 값으로 발급된다.
Thread.sleep(1000)
jwtService.renewJwt(originalJwt.refreshToken)
}
Assertions.assertThat(renewedJwt.accessToken).isNotEqualTo(originalJwt.accessToken)
Assertions.assertThat(renewedJwt.refreshToken).isNotEqualTo(originalJwt.refreshToken)
}
private fun signUp(username: String, password: String, nickname: String = "") {
memberService.signUp(
SignUp.Command(
username = username,
password = password,
nickname = nickname,
),
)
}
}
'yebali'라는 회원을 가입시키고 jwt을 발급받은 후, 발급받은 refresh token을 사용해 access token을 다시 발급받는 테스트입니다.
잘 동작합니다.
로그인/회원가입 및 권한 테스트
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
class MemberControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var objectMapper: ObjectMapper
@Test
fun `회원가입`() {
signUp(username = "yebali", password = "1234", nickname = "예발이")
.andExpect(jsonPath("$.username").value("yebali"))
.andExpect(jsonPath("$.nickname").value("예발이"))
.andExpect(status().isOk)
}
@Test
fun `로그인`() {
// 회원가입
signUp("yebali2", "1234")
// 로그인
signIn("yebali2", "1234")
.andExpect(jsonPath("$.accessToken").exists())
.andExpect(jsonPath("$.refreshToken").exists())
.andExpect(status().isOk)
}
@Test
fun `USER 권한을 가진 사용자는 USER을 위한 경로에 접근할 수 있다`() {
signUp(username = "yebali3", password = "1234")
val accessToken = signIn("yebali3", "1234")
.andReturn()
.response
.contentAsString
.let { JsonPath.parse(it).read<String>("$.accessToken") }
mockMvc.perform(
get("/member/test")
.apply { servletPath("/member/test") }
.header("Authorization", accessToken),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$").value("yebali3"))
}
@Test
fun `USER 권한을 가진 사용자는 ADMIN을 위한 경로에 접근할 수 없다`() {
signUp(username = "yebali4", password = "1234")
val accessToken = signIn("yebali4", "1234")
.andReturn()
.response
.contentAsString
.let { JsonPath.parse(it).read<String>("$.accessToken") }
mockMvc.perform(
get("/admin/test")
.apply { servletPath("/admin/test") }
.header("Authorization", accessToken),
).andExpect(status().isForbidden)
}
private fun signUp(username: String, password: String, nickname: String = ""): ResultActions {
return mockMvc.perform(
post("/member/sign-up")
.apply { servletPath("/member/sign-up") }
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
SignUpRest.Request(
username = username,
password = password,
nickname = nickname,
),
),
),
)
}
private fun signIn(username: String, password: String): ResultActions {
return mockMvc.perform(
post("/member/sign-in")
.apply { servletPath("/member/sign-in") }
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
SignInRest.Request(
username = username,
password = password,
),
),
),
)
}
}
MockMvc를 사용해 실제 API를 호출하는 것과 동일한 테스트를 진행했습니다.
회원가입 및 로그인 테스트는 응답이 잘 내려오는지 확인하고,
권한을 확인하는 테스트는 사용자의 username을 응답으로 받는지,
접근할 수 없는 경로에 요청했을 땐 403(Forbidden)을 응답으로 받는지 확인하는 테스트입니다.
역시 잘 통과했습니다.
마무리
Spring Security를 사용해서 JWT을 발급하고 인증/인가 하는 간단한 방법을 알아봤습니다.
매우 기본적인 예시이기 때문에 실무에 적용하기는 조금 아쉬운 내용입니다.
예시를 보완하기 위한 방법으로는 Filter를 사용하지 않고 JWT에 들어있는 Claim들을 @Controller에서 바로 객체로 받을 수 있도록HandlerMethodArgumentResolver을 구현하거나 Spring Gateway에서 토큰 정보를 기반으로 사용자 정보를 조회해서 PassPort를 만들어 다시 해더에 넣어주는 등 다양한 방법들이 존재합니다.
은탄환은 없으니 각자 알맞게 잘 구현하면 좋겠습니다
'Spring' 카테고리의 다른 글
Spring event와 Transaction 그리고 @EventListerner, @TransactionalEventListener (0) | 2024.11.08 |
---|---|
Spring Cloud Gateway 동적으로 Route 변경하기 (feat. spring cloud config) (0) | 2024.04.08 |
Spring Boot의 Auto Configuration (0) | 2023.10.26 |
WebSocket 구현하기 feat.Spring (0) | 2023.02.10 |
Spring Collection 조회 성능 비교 (0) | 2022.12.25 |