로그인/회원가입 기능을 위해 Spring WebFlux을 사용한 Spring Security로 JWT Authentication을 구현하는 과정을 정리해보았다.
다음 유튜브 영상을 참고했다. https://www.youtube.com/watch?v=wyl06YqMxaU
// build.gradle.kts
dependencies {
// spring security + webflux + JWT
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
// swagger
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.1.0")
클래스의 보안을 사용하여 유효성을 검사하는 데 필요한 모든 것을 제공하는 jwt 라이브러리를 사용한다.
SpringSecuirty 메인 클래스
// SpringSecuirty
@Operation(summary = "auth test")
@GetMapping(
path = ["/test"],
produces = [MediaType.APPLICATION_JSON_VALUE],
)
suspend fun test(
@AuthenticationPrincipal principal: Principal): Profile {
return Profile(principal.name)
}
현재 사용자 이름 프로필만 노출하도록 한다.
이 상태에서 실행시켜보면 스프링 자체에서 비밀번호를 생성한다.
Using generated security password: 1561181654-xxxx-xxx-xxxxx-xxx
생성할 jwt 인증에 대한 보안 구성 클래스 생성
// SecurityConfig
class SecurityConfig {
//BCrypt 해싱 알고리즘을 사용하여 비밀번호를 인코딩하고 확인
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder();
@Bean
fun userDetailsService(encoder: PasswordEncoder): MapReactiveUserDetailsService {
val user = User.builder()
.username("naonworks")
.password(encoder.encode("1234"))
.roles("USER")
.build()
return MapReactiveUserDetailsService(user)
}
// 보안 웹 필터 체인
@Bean
fun securityFilterChain(
http: ServerHttpSecurity,
): SecurityWebFilterChain? {
http
// 규칙을 설정합니다. 예를 들어, 인증, 권한, 경로별 규칙 등을 정의할 수 있습니다.
.authorizeExchange()
.pathMatchers("/login").permitAll() // 모든 사용자에게 허용
.anyExchange().authenticated()
.and()
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()// HTTP 기본 인증 사용 X
return http.build()
}
SpringSecurity 메인 클래스에 로그인 생성
// SpringSecurity
class SpringSecurity(
private val encoder: PasswordEncoder,
private val users: ReactiveUserDetailsService,
@Operation(summary = "log in")
@PostMapping(
path = ["/login"]
)
suspend fun login(@RequestBody login: Login): Jwt {
val user = users.findByUsername(login.id).awaitSingleOrNull() //개체를 반환하는 일시 중단 함수
// user가 존재하면 pw 차단
user?.let{
if(encoder.matches(login.pw, it.password)){
return Jwt("sample")
}
}
// 사용자 이름을 찾을 수 없거나 비밀번호가 일치하지 않으면 401 반환
throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
}
}
http POST :8080/login username=naonworks password=1234
{ “token”: “sample” } 아직 유효한 jwt는 아니지만 토큰을 얻었다.
이제 JWT를 구현해보자
JWT 구현 클래스
// JwtSupport.kt
class BearerToken(val value: String) : AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES){
override fun getCredentials(): Any = value // JWT 문자열을 자격 증명으로 반환하여 메서드를 재정의
override fun getPrincipal(): Any = value // JWT 문자열을 주체로 반환하여 메서드를 재정의
}
@Component
class JwtSupport {
// 문자열을 사용하여 HMAC(해시 기반 메시지 인증 코드)에 대한 비밀 키를 초기화
private val key = Keys.hmacShaKeyFor("aaaaaaaaaabbbbbbbbbbccccccccccdd12321312312sdadasd12312312312313asdsrf1245wevf243a".toByteArray())
// 이전에 정의된 키를 사용하여 JWT 파서를 초기화
private val parser = Jwts.parserBuilder().setSigningKey(key).build()
fun generate(username:String):BearerToken {
val builder = Jwts.builder()
.setSubject((username)) // 지정된 사용자 이름에 대한 JWT 생성
.setIssuedAt(Date.from(Instant.now())) // 발행시점
.setExpiration(Date.from(Instant.now().plus(15,ChronoUnit.MINUTES))) // 만료 클래임
.signWith(key) // 비밀키를 사용하여 JWT에 서명
return BearerToken(builder.compact())
}
fun getId(token: BearerToken): String {
// 파서를 사용하여 제공된 JWT에서 제목(sub)을 구문 분석하고 반환
return parser.parseClaimsJws(token.value).body.subject
}
fun isValid(token: BearerToken, user:UserDetails?):Boolean{
val claims = parser.parseClaimsJws(token.value).body // 제공된 JWT의 클레임을 구문 분석
val unexpired = claims.expiration.after(Date.from(Instant.now())) // 만료 시간이 현재 시간 이후인지 확인(JWT는 만료되지 않음).
return unexpired && (claims.subject == user?.username) // JWT가 유효한지 여부를 반환
}
}
JWT를 SpringSecurity 메인클래스에 가져오기
// SpringSecurity
class SpringSecurity(
private val encoder: PasswordEncoder,
private val users: ReactiveUserDetailsService,
private val jwtSupport: JwtSupport
@Operation(summary = "log in")
@PostMapping(
path = ["/login"]
)
suspend fun login(@RequestBody login: Login): Jwt {
val user = users.findByUsername(login.id).awaitSingleOrNull() //개체를 반환하는 일시 중단 함수
// user가 존재하면 pw 차단
user?.let{
if(encoder.matches(login.pw, it.password)){
return ResponseEntity.ok(Jwt(jwtSupport.generate(it.username).value))
}
}
// 사용자 이름을 찾을 수 없거나 비밀번호가 일치하지 않으면 401 반환
throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
}
{ “token”: “enaeklrfermafkaenfi.kjfnsdkjvdnvfdkfnsfknfnsafskajf.dnsdkf dfvjkzdnf”}
토큰의 유효성을 확인하기 위한 인증 클래스
JwtServerAuthenticationConverter : 서버 인증 변환
// Authentication.kt
// 실제 헤더 값을 추출하기 위한 jwt
@Component
class JwtServerAuthenticationConverter : ServerAuthenticationConverter {
override fun convert(exchange: ServerWebExchange?): Mono<Authentication> {
return Mono.justOrEmpty(exchange?.request?.headers?.getFirst(HttpHeaders.AUTHORIZATION))
.filter { it.startsWith("Bearer ") }.map { it.substring((7)) }.map { jwt -> BearerToken(jwt) }
}
}
// 생성한 **BearerToken을 사용하고 토큰이 유효한지 사용자가 존재하는지 토큰 검증**
@Component
class JwtAuthenticationManager(private val jwtSupport: JwtSupport, private val users: ReactiveUserDetailsService) :
ReactiveAuthenticationManager {
// 인증
override fun authenticate(authentication: Authentication?): Mono<Authentication> {
return Mono.justOrEmpty(authentication)
.filter { auth -> auth is BearerToken }
.cast(BearerToken::class.java)
.flatMap { jwt -> mono { validate(jwt) } }
.onErrorMap { error->InvalidBearerToken(error.message) }
}
// 토큰 검증
private suspend fun validate(token: BearerToken): Authentication {
val username = jwtSupport.getId(token)
val user = users.findByUsername(username).awaitSingleOrNull()
if (jwtSupport.isValid(token, user)) {
return UsernamePasswordAuthenticationToken(user!!.username, user.password, user.authorities)
}
throw IllegalArgumentException("Token is not valid")
}
}
// 인증 실패
class InvalidBearerToken(message: String?): AuthenticationException(message)
Mono.justOrEmpty(exchange?.request?.headers?.getFirst(HttpHeaders.AUTHORIZATION))
HTTP 요청에서 헤더 값을 검색
.filter { it.startsWith("Bearer ") }
"Bearer"로 시작하지 않는 값을 필터링
.map { it.substring(7) }
토큰에서 "Bearer" 접두사를 제거
.map { jwt -> BearerToken(jwt) }
추출된 토큰을 BearerToken
객체에 매핑
⇒ BearerToken
인증을 위한 객체를 생성
// SecurityConfig
class SecurityConfig {
//BCrypt 해싱 알고리즘을 사용하여 비밀번호를 인코딩하고 확인
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder();
@Bean
fun userDetailsService(encoder: PasswordEncoder): MapReactiveUserDetailsService {
val user = User.builder()
.username("naonworks")
.password(encoder.encode("1234"))
.roles("USER")
.build()
return MapReactiveUserDetailsService(user)
}
// 보안 웹 필터 체인
@Bean
fun securityFilterChain(
http: ServerHttpSecurity,
converter: JwtServerAuthenticationConverter,
authManager: JwtAuthenticationManager
): SecurityWebFilterChain? {
val filter = AuthenticationWebFilter(authManager)
filter.setServerAuthenticationConverter(converter)
http
.exceptionHandling()
.authenticationEntryPoint { exchange, _ ->
Mono.fromRunnable {
exchange.response.statusCode = HttpStatus.UNAUTHORIZED
exchange.respoense.headers.set(HttpHeaders.WWW_AUTHENTICATE, "Bearer"}
}
}
// 규칙을 설정합니다. 예를 들어, 인증, 권한, 경로별 규칙 등을 정의할 수 있습니다.
.authorizeExchange()
.pathMatchers(HttpMethod.POST, "/login").permitAll() // 모든 사용자에게 허용
.anyExchange().authenticated()
.and()
.addFilterAt(filter,SecurityWebFiltersOrder.AUTHENTICATION)
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()// HTTP 기본 인증 사용 X
return http.build()
}