OAuth - 인증 서버에 엑세스 토큰 요청 및 리소스 서버에 사용자 정보 요청 - 2
클라이언트 요청 처리
이제 인증이 성공한 클라이언트로부터 인증 코드를 받아서 사용자 정보를 백엔드(클라이언트 입장에서는)에 요청하도록 컨트롤러, 서비스, 리포지토리를 만들겠다.
컨트롤러
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@PostMapping("/auth/{provider}")
public ResponseEntity<JSONResponse<UserResponse>> login(
@PathVariable("provider") String providerName,
@RequestBody @Valid LoginRequest loginRequest
) {
return ResponseEntity.ok()
.body(JSONResponse.onSuccess(loginService.login(loginRequest.code(), providerName)));
}
}
// loginRequest.java
public record LoginRequest(
@NotBlank String code
) {
}
서비스
OAuth 제공자를 클라이언트(서비스 클래스)에서 신경 쓸 필요 없게 하기 위해서 ProviderFactory
를 설계하였다.
@Component
@RequiredArgsConstructor
public class ProviderFactory {
private final List<OAuthProvider> providers;
public OAuthProvider mapping(String providerName) {
return providers
.stream()
.filter(provider -> provider.is(providerName))
.findFirst()
.orElseThrow(() -> new CommonException(ErrorCode.INVALID_REQUEST));
}
}
is
메소드는 Stream
객체의 filter()
에서 제공자 이름과 주어진 경로 변수 값을 비교하여 이름이 같은 경우 true
가 되어 해당 OAuthProvider
구현체가 반환된다. 그리고 이 코드에서 가장 중요한 것은 같은 상위 클래스 또는 인터페이스를 상속받은 빈은 다형성에 의해서 List
로 상속받은 모든 빈 객체의 의존성 주입을 받을 수 있다는 것이다! 나는 몰랐다…
@Service
@RequiredArgsConstructor
public class LoginService {
private final LoginRepository loginRepository;
private final ProviderFactory providerFactory;
public UserResponse login(String code, String providerName) {
OAuthProvider provider = providerFactory.mapping(providerName);
UserInfoDto userInfo = provider.authenticate(code);
// 사용자가 데이터베이스에 존재하지 않으면 데이터베이스에 삽입 후 사용자 엔티티를 반환
User user = getOrCreateUser(userInfo);
// 엔티티로부터 응답값을 변환 후 반환
return UserResponse.convertFrom(user);
}
private User getOrCreateUser(UserInfoDto userInfoDto) {
User authUser = null;
if (userInfoDto.getAuthType() == AuthType.KAKAO) {
authUser = loginRepository.findByNickname(userInfoDto.getName());
} else {
authUser = loginRepository.findByEmail(userInfoDto.getEmail());
}
return authUser == null ? createUser(userInfoDto) : authUser;
}
private User createUser(UserInfoDto userInfoDto) {
User persistResult = null;
if (userInfoDto.getAuthType() == AuthType.KAKAO) {
persistResult = User.builder()
.nickname(userInfoDto.getName())
.authType(userInfoDto.getAuthType())
.build();
} else {
persistResult = User.builder()
.email(userInfoDto.getEmail())
.nickname(userInfoDto.getName())
.authType(userInfoDto.getAuthType())
.build();
}
loginRepository.save(persistResult);
return persistResult;
}
}
한 가지 아쉬운 점은 카카오 OAuth의 경우 비즈니스 어플리케이션이 아니면 사용자 이름과 프로필 사진만 요청할 수 있기 때문에 쓸데없는 조건문을 추가해야 했다.
레포지토리
@Repository
public interface LoginRepository extends JpaRepository<User, Long> {
// 사용자 이메일 기준으로 엔티티 조회(구글)
User findByEmail(String email);
// 사용자 이름 기준으로 엔티티 조회(카카오)
User findByNickname(String nickname);
}
테스트
테스트는 만들어 두었던 클라이언트가 잘 작동됐다는 가정 하에 응답만 보도록 하겠다.
구글
카카오
데이터베이스
번외
서비스에서 ProviderFactory
를 이용하여 OAuthProvider
구현체를 가져왔는데 이 방법은 어떨까 싶어서 기록해둔다. 스프링에 ArgumentResolver
를 추가로 등록하여 서비스에서 ProviderFactory
에 의존하지 않도록 만들 것이다.
ArgumentResolver 등록
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthProviderPicker {
}
@Component
@RequiredArgsConstructor
public class ProviderArgumentResolver implements HandlerMethodArgumentResolver {
private final ProviderFactory providerFactory;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter
.withContainingClass(OAuthProvider.class)
.hasParameterAnnotation(AuthProviderPicker.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
// path variable이라 / 기준으로 나누고 마지막 문자열을 가져오도록 함
// 이 방식은 쿼리 파라미터가 더 나을 것 같다.
// String[] pathVariableFromRequest = request.getRequestURI().split("/");
// String providerName = pathVariableFromRequest[pathVariableFromRequest.length - 1];
// 키 값으로 접근이 가능하기 때문이다.
String providerName = request.getParameter("provider");
log.info(providerName);
return providerFactory.mapping(providerName);
}
}
@Configuration
@RequiredArgsConstructor
public class AuthResolverConfig implements WebMvcConfigurer {
private final ProviderArgumentResolver providerArgumentResolver;
@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(providerArgumentResolver);
}
}
요청 메소드 및 서비스 수정
public class LoginController {
private final LoginService loginService;
@PostMapping("/auth")
public ResponseEntity<JSONResponse<UserResponse>> login(
@AuthProviderPicker OAuthProvider provider,
@RequestBody @Valid LoginRequest loginRequest
) {
return ResponseEntity.ok()
.body(JSONResponse.onSuccess(loginService.login(loginRequest.code(), provider)));
}
}
@Service
@RequiredArgsConstructor
public class LoginService {
private final LoginRepository loginRepository;
public UserResponse login(String code, OAuthProvider provider) {
UserInfoDto userInfo = provider.authenticate(code);
User user = getOrCreateUser(userInfo);
return UserResponse.convertFrom(user);
}
}
참고로 쿼리 파라미터를 사용해야 코드가 더 깔끔하기 때문에 클라이언트에서 요청할 때 제공자의 이름을 쿼리 파라미터로 전달해줘야 한다.
// 기존 API 사용 시
fetch('http://localhost:8080/api/user/auth/google')
// Argument Resolver + 쿼리 파라미터 사용 시
fetch('http://localhost:8080/api/user/auth?provider=google')
아무튼 이런식으로 만들면 서비스에서 ProviderFactory
를 의존할 필요가 없어진다. 또한 (아마도) 메시지 처리 때 다뤘던 수정자 주입을 사용하면 ProviderFactory
를 정적 메소드로 활용하여 ArgumentResolver
에서도 의존성 주입을 할 필요가 없을 것 같다. 뭐가 더 나은지는 모르겠다.
스프링 시큐리티 없이 OAuth 기능 구현을 완료하였다. 이제 사용자 정보를 응답하는 것이 아닌 JWT를 응답하도록 구현할 차례다.
여담으로 3일 정도 걸린 것 같은데 알고보니 2일차에 완성해놓고 로직에 테스트 코드를 하나 안지워서 계속 오류가 발생해서 하루를 날렸다. 디버깅할 때 파일 하나에 꽂히지 말고 전체적으로 보도록 해야겠다고 생각하였다.
댓글남기기