팀원과 회의하면서 프론트엔드와 백엔드 사이에 oauth2를 어떤 방식으로 적용할 건지에 대해서 논의를 했다. 우선 기본적인 방식은 spring security에서 security Configuration에 적용만 하면 authroziation code부터 사용자 정보까지 굉장히 쉽게 얻어 올 수 있다.
그러나 이러한 방식의 문제점은 백엔드에서 프론트엔드에 응답할 때 다시 프론트 페이지로 리다이렉트를 해야 하고 리다이렉트시 body에 access token을 실어서 보낼 수 없기 때문에 쿼리스트링에 노출시키거나 쿠키를 활용해야 한다.(쿼리스트링의 경우 URI에 노출되기 때문에 안전하지 않고, 쿠키도 브라우저에 저장하기 때문에 안전하다고 볼 수 없다.) 또한 rest api가 페이지에 관여해서 restful 하지 않다는 생각을 했다.
해결 방법은 Authorization code를 프론트에서 요청하는 방법이다. 이 방법의 장점은 리다이렉트 관련 작업은 프론트에게 맡기고 진짜 필요한 리소스를 백엔드에게 restful 하게 요청하는 것이다. 이렇게 활용하면 백엔드는 필요한 데이터만 넘기고 이것을 프론트는 받아서 처리만 하면 된다. 프론트에서 보통 로그인 시 필요한 리소스는 백엔드 api에 접근하기 위해 필요한 access token일 것이다.
Authorization code를 얻는 것은 프론트에게 맡기고 authorization code를 포함한 데이터를 요청하면 access token을 주는 방식으로 설계해야한다.
단계
사용자 데이터를 영속화하는 과정은 빠졌는데 OAtuth2의 동작 방식에 대해서 집중하고자 우선 배제했다.
관련 정보는 많은 블로그에 잘 정리되어 있어서 간략히만 설명하겠다.
Kakao Developers 페이지에 들어가면 다음과 같이 정보를 제공하는데 카카오 로그인 문서 보기를 누르고 rest api를 누르면
다음과 같이 인증 방식을 어떻게 처리하는지 잘 나와있다. 해당 관련 문서를 보면서 각 api 별로 필요한 정보를 얻어오면 된다.
spring에서 restTemplate을 활용해 OAuth 서버에 access token 및 사용자 정보를 요청할 수 있다.
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
}
우선 가장 중요한 것은 Bean에 RestTemplate을 등록하는 것이다. RestTemplate을 Bean으로 등록하는 이유는 RestTemplate은 상태가 없는 객체이며 api를 호출하는 용도로만 사용하기 때문에 Bean으로 인한 싱글톤 구조에도 쓰레드 세이프하다. 또한, Bean으로 등록해서 주입하는 방식을 활용하면 테스트 시 모킹이 간단해진다. 이러한 장점 때문에 Bean으로 등록한다.
@Component
@Slf4j
@RequiredArgsConstructor
public final class CustomOAuth2Client {
private final ClientRegistrationRepository clientRegistrationRepository;
private final RestTemplate restTemplate;
public OAuth2AccessTokenResponse requestAccessToken(String registrationId,
String authorizationCode) {
ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(
registrationId);
ProviderDetails provider = registration.getProviderDetails();
HttpEntity<MultiValueMap<String, String>> requestAccessTokenEntity = setRequestAccessTokenEntity(
registration, authorizationCode);
ResponseEntity<OAuth2AccessTokenResponse> responseAccessTokenEntity = restTemplate.exchange(
provider.getTokenUri(),
HttpMethod.POST,
requestAccessTokenEntity,
OAuth2AccessTokenResponse.class
);
if (responseAccessTokenEntity.getStatusCode() == HttpStatus.OK) {
return responseAccessTokenEntity.getBody();
}
throw new IllegalArgumentException(
String.format("Failed to get access token from %s API",
registration.getClientName()));
}
public OAuth2UserAttribute requestUserInfo(String registrationId,
OAuth2AccessTokenResponse oauth2AccessToken) {
ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(
registrationId);
ProviderDetails provider = registration.getProviderDetails();
HttpEntity<String> requestUserInfoEntity = setRequestUserInfoEntity(
oauth2AccessToken.getAccessToken());
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(
provider.getUserInfoEndpoint().getUri(),
HttpMethod.GET,
requestUserInfoEntity,
new ParameterizedTypeReference<>() {
}
);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return UserInfoExtractor.getInstance(registrationId).extract(responseEntity.getBody());
}
throw new IllegalArgumentException(
String.format("Failed to get userInfo from %s API",
registration.getClientName()));
}
private HttpEntity<MultiValueMap<String, String>> setRequestAccessTokenEntity(
ClientRegistration registration, String authorizationCode) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", registration.getClientId());
params.add("redirect_uri", registration.getRedirectUri());
params.add("code", authorizationCode);
params.add("client_secret", registration.getClientSecret());
return new HttpEntity<>(params, headers);
}
private HttpEntity<String> setRequestUserInfoEntity(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
return new HttpEntity<>("", headers);
}
}
우선 CustomOAuth2Client 객체를 만든 이유를 설명하면 해당 객체로 분리해서 요청하는 로직을 짠 이유는 외부에 요청하는 작업은 인프라스트럭처에 가깝기 때문이다. 그렇기에 이를 응용로직이나 컨트롤러에서 활용하려면 해당 레이어에서 직접 구현하기 보다는 분리해서 표현하는게 테스트하기도 쉽고 구조상 더 적합하다고 생각했다.
ClientRegistration은 OAuth2 client 에서 제공하는 객체로 application.yml에 등록된 oauth2 관련 정보를 쉽게 꺼내 쓸 수 있다.
지금 위의 코드는 restTemplate에 요청 실패시 예외에 대해서 안전하지 않은 코드이다. 실제 프로젝트에 적용하려면 exception을 handling 하는 코드를 추가로 구현해야 한다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String USERNAME_CLAIM_KEY = "username";
private static final String EMAIL_CLAIM_KEY = "email";
private final JwtProperties jwtProperties;
public String createToken(OAuth2UserAttribute attribute) {
// ...
}
public Authentication getAuthentication(String token) {
// ...
}
public boolean validateToken(String token) {
// ...
}
}
해당 코드는 jwtToken관련 처리를 담당하는 객체이다. provider는 oauth 서버로 부터 받은 사용자 정보의 일부분을 추출해서 access token으로 전달하기위한 과정과 Filter에서 검증 및 SecurityContextHolder에 Authentication token을 등록하는 중간 단계에서 access token을 검증하고 다시 객체로 변환하는 역할을 담당한다.
이제 프론트에서 요청할 엔드 포인트 및 관련 API를 구현하면 된다. 다음은 간단한 Controller와 Service 코드이다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/oauth")
@Slf4j
public class AuthController {
private final Oauth2Service oauth2Service;
@GetMapping("/login/{registrationId}")
public ResponseEntity<String> getUserInfo(
@PathVariable("registrationId") String registrationId,
@RequestParam("code") String authorizationCode) {
String accessToken = oauth2Service.requestUserInfo(registrationId,
authorizationCode);
return ResponseEntity.ok(accessToken);
}
}
@Slf4j
@RequiredArgsConstructor
@Service
public class Oauth2Service {
private final CustomOAuth2Client oAuth2Client;
private final JwtTokenProvider jwtTokenProvider;
public String requestUserInfo(String registrationId, String authorizationCode) {
OAuth2AccessTokenResponse accessTokenResponse = oAuth2Client.requestAccessToken(
registrationId, authorizationCode);
OAuth2UserAttribute oAuth2UserAttribute = oAuth2Client.requestUserInfo(registrationId,
accessTokenResponse);
return jwtTokenProvider.createToken(oAuth2UserAttribute);
}
}
보통 로그인은 Http Method를 Post 방식으로 많이 가져가는데 브라우저로 쉽게 테스트 하고자 Get 방식으로 구성했다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_HEADER = "Authorization";
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String accessToken = parseJwtAccessToken(request);
if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
Authentication auth = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (JwtAuthenticationException e) {
SecurityContextHolder.clearContext();
response.sendError(HttpStatus.UNAUTHORIZED.value(), e.getMessage());
return;
}
filterChain.doFilter(request, response);
}
private String parseJwtAccessToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
}
return null;
}
}
위의 코드를 간략하게 요약하자면 access token이 유효한지 확인하고 유효하다면 securityHolder에 Authentication을 올려놓아 인증을 완료한다.
parseJwtAccessToken이 null을 리턴하는 것은 마음에 들지 않지만 parseJwtAccessToken을 실패했다고 해서 필터가 막히면 안된다. 그 이유는 요청이 Authorization이 없는 요청도 있을 수 있기 때문이다. throw 던지고 추가로 catch하는 방법도 괜찮을 수 있다.
메서드로 분리한 이유는 parseJwtAccessToken이 해당 필터에서만 쓰일 것이라 생각했기 때문이다. 만약 Bearer로 Authorization을 parsing하는 작업이 다른 로직에서도 필요하다면 클래스로 분리하는 것이 확장성에 유리하고 테스트도 더 쉽게 할 수 있다고 생각한다.