JWT Authentication With Spring WebFlux And Spring Security

dyeon-dev·2023년 11월 16일
0

로그인/회원가입 기능을 위해 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

  • http를 사용하고 있는지 확인하기 위해서 실행 http :8080/test → 부여된 인증이 없기 때문에 401 Unauthorized 발생
  • 부여된 비밀번호로 인증하여 실행 http: 8080/test -a user: 1561181654-xxxx-xxx-xxxxx-xxx → { “username”: “user” }

생성할 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”}

  • “sub” : “naonworks”
  • “iat” : “토큰이 발행된 날짜”
  • “exp” : “토큰이 만료되는 날짜”

토큰의 유효성을 확인하기 위한 인증 클래스

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()
    }
profile
https://dyeon-dev.vercel.app/ ⬅️ 블로그 이전

0개의 댓글