스프링 MVC로 할 때 OAuth2 로그인 구현이 쉬웠던 건 참고 자료가 많기 때문이었다. 웹플럭스는 자료가 거의 없었다. 그래서 내부 동작을 모르면 따라치는 것만으로는 구현할 수 없었다. 그 과정을 기록한다.
1. OAuth2 구현 위한 전체적인 흐름 예상
스프링 시큐리티에서 자동으로 해주는게 많아서 원래라면 몇 번 요청을 주고받아야 했던 것을 설정 파일 등록으로 거의 해결이 된다.
- "{baseUrl}/login/oauth2/authorization/google"로 가면 구글 로그인 페이지가 뜸. 계정을 클릭해서 로그인 하면,
- 여긴 블랙박스
- 아마 처음에는 구글 쪽에서 code를 리턴하고,
- 그 code로 액세스 토큰, 리프레시 토큰을 요청하는 것 같음
- 여차저차 해서 승인된 리다이렉트 url인 "{baseUrl}/login/oauth2/code/google"로 리다이렉트하면서 로그인한 사용자 정보 이것저것 같이 줌(어떻게 주는 지는 모름)
이렇게 이해를 했고 나는 아래와 같은 과정만 거치면 구현이 될 거라고 생각했다.
- build.gradle에 'org.springframework.boot:spring-boot-starter-oauth2-client' 의존성 추가
- application.yml에 구글 클라이언트 아이디, 시크릿 키 작성
- 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 |