쑥멍
쑥로그
쑥멍
전체 방문자
오늘
어제
  • 분류 전체보기 (65)
    • 메모 (0)
    • 안드로이드 (4)
      • 팁 (1)
      • 프로젝트 (3)
    • 파이썬 (1)
    • 스프링 (26)
      • 프로젝트 (16)
      • 에러 아카이빙 (1)
      • 해부 (9)
      • 튜토리얼 (0)
    • 리눅스 (3)
    • CS (14)
      • 컴퓨터구조 & OS (8)
      • 클린 아키텍처 (6)
    • 낙서 (5)
      • 일기 (0)
      • TIL (2)
      • 고민 (2)
    • 게임 (1)
      • 야숨 (1)
    • C (0)
    • Go (3)
    • Django (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • ㅜ
  • ㅁ

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
쑥멍

쑥로그

스프링/프로젝트

Spring WebFlux + OAuth2 로그인 구현 과정

2023. 11. 11. 11:19

스프링 MVC로 할 때 OAuth2 로그인 구현이 쉬웠던 건 참고 자료가 많기 때문이었다. 웹플럭스는 자료가 거의 없었다. 그래서 내부 동작을 모르면 따라치는 것만으로는 구현할 수 없었다. 그 과정을 기록한다. 


1. OAuth2 구현 위한 전체적인 흐름 예상

스프링 시큐리티에서 자동으로 해주는게 많아서 원래라면 몇 번 요청을 주고받아야 했던 것을 설정 파일 등록으로 거의 해결이 된다. 

  1. "{baseUrl}/login/oauth2/authorization/google"로 가면 구글 로그인 페이지가 뜸. 계정을 클릭해서 로그인 하면,
  2. 여긴 블랙박스
    • 아마 처음에는 구글 쪽에서 code를 리턴하고, 
    • 그 code로 액세스 토큰, 리프레시 토큰을 요청하는 것 같음
  3. 여차저차 해서 승인된 리다이렉트 url인 "{baseUrl}/login/oauth2/code/google"로 리다이렉트하면서 로그인한 사용자 정보 이것저것 같이 줌(어떻게 주는 지는 모름) 

이렇게 이해를 했고 나는 아래와 같은 과정만 거치면 구현이 될 거라고 생각했다.

  1. build.gradle에 'org.springframework.boot:spring-boot-starter-oauth2-client' 의존성 추가
  2. application.yml에 구글 클라이언트 아이디, 시크릿 키 작성
  3. SecurityConfig에서 .oauth2Login() 한 줄로 설정
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: "id"
            client-secret: "secret"
            redirect-uri: "http://localhost:8080/login/oauth2/code/google"
            scope:
              - email
              - profile
              - openid

application.yml은 이렇게 설정했다. 저 redirect-uri는 스프링 시큐리티에서 인식하는 디폴트 uri라고는 하던데 불안해서 추가했다. 중요한 건 저 uri에 대한 컨트롤러를 만들어 처리할 필요는 없다. 그 부분은 스프링 시큐리티가 알아서 해준다. 다행인 일이다.

 

@Configuration
@EnableWebFluxSecurity
class SecurityConfig() {

    @Bean
    fun securityWebFilterChain(
            http: ServerHttpSecurity,
            authManager: JwtAuthenticationManager,
            converter: JwtAuthenticationConverter,
    ): SecurityWebFilterChain {
        val filter = AuthenticationWebFilter(authManager)
        filter.setServerAuthenticationConverter(converter)

        return http
                .csrf { it.disable() }
                .authorizeExchange {
                    exchanges ->
                    exchanges
                            .pathMatchers("/api/user/**").permitAll()
//                            .pathMatchers("/oauth2/**").permitAll()
//                            .pathMatchers("/login/oauth2/**").permitAll()
                            .anyExchange().authenticated()
                }
                .oauth2Login { }	// ⭐️
                .addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION)
                .build()
    }
    ...
}

별표친 라인만 보면 된다. 저 한 줄만 추가했다. 그럼 스프링 시큐리티가 알아서 다 해주는 줄 알았다. (다 해주는 것 맞긴 하다)

 


2. 놓친 부분 & 해결책

로그인 페이지는 접속이 되지만, 계정 클릭 후 로그인 후처리가 안 되는 것 같았다. 리다이렉트도 안 됐고. 

 

리다이렉트는 SuccessHandler를 설정하면 된다. 그런데 리다이렉트만 된다고 해서 끝나는 게 아니고, 구글 쪽에서 보내준 구글 사용자 정보를 내 DB에 저장해야 한다. 이 부분만 직접 구현하면 된다. 나머지는 스프링 시큐리티가 알아서 다 해준다.

 

스프링 시큐리티가 해주는 것

  • 구글 서버에 code 요청하고, 토큰 받아오고, 사용자 정보도 가져와줌
  • 알아서 리다이렉트 해줌
  • SecurityContextHolder에 사용자 등록도 해줌

내가 구현해야되는 것

  • 리다이렉트 후 받은 구글 사용자 정보를 내 DB에 저장
  • JWT 인증 방식이라면 토큰도 생성
  • 토큰과 함께 사용자를 적절한 곳에 리다이렉트시키기(이 이후 받은 토큰을 쿠키 등에 저장하는 건 프론트 쪽 일인 것 같음)
    • 쿠키에 담아서 리턴하는 게 안전하다고는 하던데 기력이 없어서 일단 uri에 담아서 리턴하는 걸로 구현함 
  • 이걸 그냥 SuccessHandler 한 곳에서 다 처리하면 됨
@Component
class OAuth2AuthenticationSuccessHandler(
    private val jwtSupport: JwtSupport,
    private val userRepository: ReactiveUserRepository,
): ServerAuthenticationSuccessHandler {
    override fun onAuthenticationSuccess(webFilterExchange: WebFilterExchange?, authentication: Authentication?): Mono<Void> {
        return Mono.justOrEmpty(authentication)
                .flatMap { auth ->
                    val oidcUser = auth.principal as OidcUser

                    userRepository.findByEmail(oidcUser.email)
                            .switchIfEmpty(Mono.defer {
                                val user = User(
                                        email = oidcUser.email,
                                        password = null,
                                        nickname = oidcUser.nickName,
                                        provider = "google",
                                )
                                userRepository.save(user)
                            })
                            .flatMap{ user ->
                                val token = jwtSupport.generate(user.email!!)

                                val redirectUri = UriComponentsBuilder.fromUriString("/oauth2/callback")
                                        .queryParam("token", token.value)
                                        .build().toUriString()

                                if (webFilterExchange != null) {
                                    webFilterExchange.exchange.response.statusCode = HttpStatus.FOUND
                                    webFilterExchange.exchange.response.headers.location = URI.create(redirectUri)
                                }

                                Mono.empty<Void>()
                            }
                }
                .then()
    }
}

이 코드를 각자 서버 상황에 맞게 고쳐서 복붙하면 아마 잘 작동될 것이다. 

나는 이 코드를 쓰면서 괴로웠다. 원래는 이 서버의 구현 방식을 코루틴으로 결정했었고, 리포지토리도 모두 CoroutineCrudRepository를 사용했다. 그런데 리액티브 프로그래밍과 코루틴이 다른 패러다임이라는 걸 구현 도중에 깨달았다. 그래서 flatMap 안에서 suspend 함수를 호출하고 왜 안되는지 답답해하고 그랬다. 그런데 둘 중 하나를 선택해서 해야한다고 한다. 

 

하지만 위 코드의 onAuthenticationSuccess()의 리턴 타입은 Mono이다. 그래서 다른 곳은 모두 코루틴 방식으로 했는데 이 SuccessHandler만 생뚱맞게 리액티브 방식으로 구현해서 마음이 안 좋다. 게다가 UserRepository를 두 개 만들어놨다. 하나는 CoroutineCrudRepository를, 나머지 하나는 ReactiveMongoRepository를 상속한다. 별로인 것 같지만 어쩔 수 없다. 일단 OAuth2 로그인이 성공하면 된 것 아닐까 하는 생각이 든다. 

 

.oauth2Login {
    it.authenticationSuccessHandler(successHandler)
}

마지막으로 SecurityConfig에서 위에서 만든 SuccessHandler를 등록하면 끝이다. 

 


3. 인증 후 사용자 정보, 토큰 필요할 때

바로 전에 언급한 것처럼, 스프링 시큐리티가 알아서 ReactiveSecurityContextHolder에 구글 사용자 정보를 등록해준다. 개발자가 수동으로 등록할 필요 없다. 꺼내서 쓰기만 하면 된다. 그런데 사용자 정보를 꺼내는 건 간단하지만, 그 외 구글의 타 API에 요청할 때 필요한 액세스 토큰은 과정이 약간 더 복잡하다.

 

사용자 정보는 이렇게 하면 된다. 코드..

 

나는 구글 캘린더 API 연동을 위해 액세스 토큰이 필요했다. 

@Service
class OAuth2TokenService(
        private val clientRepository: ServerOAuth2AuthorizedClientRepository,
) {
    fun getAccessToken(exchange: ServerWebExchange): Mono<String> {
        val authentication = exchange.getPrincipal<OAuth2AuthenticationToken>()

        return authentication.flatMap { auth ->
            val clientRegistrationId = auth.authorizedClientRegistrationId
            clientRepository.loadAuthorizedClient<OAuth2AuthorizedClient>(clientRegistrationId, auth, exchange)
                    .map { it.accessToken.tokenValue }
        }
    }
}

ServerOAuth2AuthorizedClientRepository라는 처음 보는 클래스를 주입받아서 써야 한다. 그냥 ReactiveSecurityContextHolder로 가져올 수 있으면 좋을 텐데. 왜 이렇게 해야하는지는 모르겠지만 이렇게 해야 액세스 토큰을 얻을 수 있으니 그냥 했다. 보안 때문이라는데 납득은 안 갔다. 

 

인자로 받은 ServerWebExchange는 핸들러에서 ServerRequest.exchange()로 얻으면 된다. 

 

 

'스프링 > 프로젝트' 카테고리의 다른 글

[스프링] 복잡한 비즈니스 로직 추상화  (0) 2024.03.01
[스프링부트] 트랜잭션에서 외부 API 호출하는 문제에 대해  (0) 2023.11.14
[스프링부트] 교착 상태 테스트 & 해결 과정  (0) 2023.10.29
[스프링부트] 성능 테스트 & 튜닝 과정  (0) 2023.09.16
[스프링부트] 트랜잭션 때문에 애먹은 지점  (0) 2023.07.06
    '스프링/프로젝트' 카테고리의 다른 글
    • [스프링] 복잡한 비즈니스 로직 추상화
    • [스프링부트] 트랜잭션에서 외부 API 호출하는 문제에 대해
    • [스프링부트] 교착 상태 테스트 & 해결 과정
    • [스프링부트] 성능 테스트 & 튜닝 과정
    쑥멍
    쑥멍

    티스토리툴바