본문 바로가기

스프링

[Security] Spring JWT 인증 With REST API(OAuth2.0 추가) (3)

시작하기에 앞서

지금까지 JWT를 활용하여 로그인을 구현해보았다.

하지만, 우리가 다양한 페이지들을 돌아다니다보면 구글 로그인, 네이버 로그인, 카카오 로그인 등 다양한 소셜 로그인이 존재하는 것을 볼 수 있다.

이는 전에 말했던 것처럼 OAuth2.0 방식으로 해당 리소스 서버에 인증을 받아 자원을 꺼내오는 식으로 진행된다. (OAuth 2.0, JWT (tistory.com))

이를 실제로 구현해보려고 한다.

전체적인 로직을 구성하는데 있어서는 다음과 같이 구상해보았다.

먼저 프론트단에서 소셜 로그인을 진행하는 버튼을 구성하고 사용자는 해당 버튼을 클릭해 로그인을 진행한다.

그렇다면, 사용자는 Authentication Code를 발급받게되고 프론트에서는 이를 헤더에 실어 Server에 요청을 보낸다.

서버는 해당 code를 받고 Authentication Server에 필요한 정보들을 담아 Access Token을 요청한다.

Authentication Server는 검증 후 Access Token을 주게 된다.

서버는 받은 Token을 기반으로 Resource Server에 실제로 이메일 정보를 요청하고 검증 후에 이메일 정보를 반환해 준다.

해당 이메일 정보를 기반으로 회원가입이 되어있다면 단순 Access Token(JWT), Refresh Token(JWT) 발행 되어있지 않다면 회원가입 후 발행해준다.

이렇게 되면 추후 인증에 대해서도 Access Token(JWT)로 인증이 가능하며 만료되더라도 DB에 저장중인 Refresh Token을 통해 재발급하여 인증이 가능하다.

이를 실제로 구현해보자.

OAuth 2.0 With JWT

우리는 시작하기 전에 진행해야할 소셜 서비스에 대해 들어가 등록해야한다.

등록을 진행하면 클라이언트를 구분할 Client_id, Client_secret을 발급해준다. 그리고 로그인이 완료된 후 code와 함께 redirect되야할 redirect_url을 설정해준다.

이 부분은 구현하는데 꼭 필요한 사항이니 진행해야된다.

또한, 실제로 로그인을 진행하는 부분에 대한 url, OAuth의 Access Token을 발급받을 url, 정보를 받아오는 url 또한 알아야 한다.

이러한 정보는 yml에서 관리하였다.

spring:
    social:
        kakao:
          client_id: 
          redirect: http://localhost:8080/social/login/kakao
          url:
            login: https://kauth.kakao.com/oauth/authorize
            token: https://kauth.kakao.com/oauth/token
            profile: https://kapi.kakao.com/v2/user/me

        google:
          client_id: 
          client_secret: 
          redirect: http://localhost:8080/social/login/google
          url:
            login: https://accounts.google.com/o/oauth2/v2/auth
            token: https://oauth2.googleapis.com/token
            profile: https://www.googleapis.com/oauth2/v3/userinfo

        naver:
          client_id: 
          client_secret: 
          redirect: http://localhost:8080/social/login/naver
          url:
            login: https://nid.naver.com/oauth2.0/authorize
            token: https://nid.naver.com/oauth2.0/token
            profile: https://openapi.naver.com/v1/nid/me
  • client_id - 클라이언트를 구분해주는 ID, 등록하면 자동으로 발급해준다.
  • client_secret - 클라이언트의 PW, 등록하면 자동으로 발급해준다.
  • login - 소셜마다 로그인을 진행하는 사이트, 필요한 파라미터를 입력해줘야한다.
  • token - Access Token을 발급받는 url, 필요한 정보를 담아 보내야한다.
  • profile - Access Token을 통해 정보를 받는 url, Access Token을 담아 보내야한다.

Login Controller

실제 로그인하는 부분을 살펴보자.

다음과 같이 @Value 어노테이션을 활용해 yml의 값을 불러와 필요한 파라미터를 로그인 url에 붙여 url을 완성시킨 후 View에 넘겨 로그인을 완성시키고 있다.

이 부분은 실제로 프론트 단에서 구현해야하나, 테스트를 위해 구현해두었다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/social/login")
public class SocialController {

    private final Environment env;
    private final ProviderService providerService;

    @Value("${spring.social.kakao.client_id}")
    private String kakaoClientId;

    @Value("${spring.social.kakao.redirect}")
    private String kakaoRedirect;

    @Value("${spring.social.google.client_id}")
    private String googleClientId;

    @Value("${spring.social.google.redirect}")
    private String googleRedirect;

    @Value("${spring.social.naver.client_id}")
    private String naverClientId;

    @Value("${spring.social.naver.redirect}")
    private String naverRedirect;

    // 카카오 로그인 페이지 테스트
    @GetMapping()
    public ModelAndView socialKakaoLogin(ModelAndView mav) {
        StringBuilder loginUrl1 = new StringBuilder()
                .append(env.getProperty("spring.social.kakao.url.login"))
                .append("?client_id=").append(kakaoClientId)
                .append("&response_type=code")
                .append("&redirect_uri=").append(kakaoRedirect);

        StringBuilder loginUrl2 = new StringBuilder()
                .append(env.getProperty("spring.social.google.url.login"))
                .append("?client_id=").append(googleClientId)
                .append("&response_type=code")
                .append("&scope=email%20profile")
                .append("&redirect_uri=").append(googleRedirect);

        StringBuilder loginUrl3 = new StringBuilder()
                .append(env.getProperty("spring.social.naver.url.login"))
                .append("?client_id=").append(naverClientId)
                .append("&response_type=code")
                .append("&state=project")
                .append("&redirect_uri=").append(naverRedirect);

        mav.addObject("loginUrl1", loginUrl1);
        mav.addObject("loginUrl2", loginUrl2);
        mav.addObject("loginUrl3", loginUrl3);
        mav.setViewName("login");
        return mav;
    }

    // 인증 완료 후 리다이렉트 페이지
    @GetMapping(value = "/{provider}")
    public ModelAndView redirectKakao(ModelAndView mav, @RequestParam String code, @PathVariable String provider) {
        mav.addObject("code", code);
        mav.setViewName("redirect");
        return mav;
    }
}

이 부분에 대한 페이지를 접속하면 다음과 같이 로그인에 대한 각각의 부분이 존재하고

설정한 리다이렉트 페이지에 Authentication Code가 오게된다.

위에서 보이는 리다이렉트를 처리해주는 컨트롤러 부분에서 Code를 받아와 실제 코드를 확인해주는 부분이다.

카카오 로그인을 진행해보면 다음과 같이 Authentication Code가 잘 오는 모습을 확인할 수 있다.

이 코드를 통해 실제 서버에서는 Access Token을 발급받고 Access Token으로 이메일 정보를 받아와야한다.

이 부분에 대해서 살펴보자.

먼저, Access Token을 발급받는 부분이다.

Provider Service

실제 소셜 서비스의 Access Token을 발급받는 메소드이다.

public AccessToken getAccessToken(String code, String provider) {
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    OAuthRequest oAuthRequest = oAuthRequestFactory.getRequest(code, provider);
    HttpEntity<LinkedMultiValueMap<String, String>> request = new HttpEntity<>(oAuthRequest.getMap(), httpHeaders);

    ResponseEntity<String> response = restTemplate.postForEntity(oAuthRequest.getUrl(), request, String.class);
    try {
        if (response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), AccessToken.class);
        }
    } catch (Exception e) {
        throw new CommunicationException();
    }
    throw new CommunicationException();
}

우리는 Access Token을 요청하기 위해 Authentication Code를 비롯하여 client_id, grant_type 등 다양한 정보들을 입력해야한다.

이 부분을 통해 Resource Server가 검증하기 때문이다.

하지만, 소셜 서비스마다 조금씩 받는 정보가 다르다.

이에 따라, 소셜 서비스마다의 정보 제공을 다르게 진행해야한다.

이를 위해 각각의 소셜마다 정보를 생성해주는 OAuthRequestFactory를 구성했다.

@Component
@RequiredArgsConstructor
public class OAuthRequestFactory {

    private final KakaoInfo kakaoInfo;
    private final GoogleInfo googleInfo;
    private final NaverInfo naverInfo;

    public OAuthRequest getRequest(String code, String provider) {
        LinkedMultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        if (provider.equals("kakao")) {
            map.add("grant_type", "authorization_code");
            map.add("client_id", kakaoInfo.getKakaoClientId());
            map.add("redirect_uri", kakaoInfo.getKakaoRedirect());
            map.add("code", code);

            return new OAuthRequest(kakaoInfo.getKakaoTokenUrl(), map);

        } else if(provider.equals("google")) {
            map.add("grant_type", "authorization_code");
            map.add("client_id", googleInfo.getGoogleClientId());
            map.add("client_secret", googleInfo.getGoogleClientSecret());
            map.add("redirect_uri", googleInfo.getGoogleRedirect());
            map.add("code", code);

            return new OAuthRequest(googleInfo.getGoogleTokenUrl(), map);
        } else {
            map.add("grant_type", "authorization_code");
            map.add("client_id", naverInfo.getNaverClientId());
            map.add("client_secret", naverInfo.getNaverClientSecret());
            map.add("redirect_uri", naverInfo.getNaverRedirect());
            map.add("state", "project");
            map.add("code", code);

            return new OAuthRequest(naverInfo.getNaverTokenUrl(), map);
        }
    }

    public String getProfileUrl(String provider) {
        if (provider.equals("kakao")) {
            return kakaoInfo.getKakaoProfileUrl();
        } else if(provider.equals("google")) {
            return googleInfo.getGoogleProfileUrl();
        } else {
            return naverInfo.getNaverProfileUrl();
        }
    }

    @Getter
    @Component
    static class KakaoInfo {
        @Value("${spring.social.kakao.client_id}")
        String kakaoClientId;
        @Value("${spring.social.kakao.redirect}")
        String kakaoRedirect;
        @Value("${spring.social.kakao.url.token}")
        private String kakaoTokenUrl;
        @Value("${spring.social.kakao.url.profile}")
        private String kakaoProfileUrl;
    }

    @Getter
    @Component
    static class GoogleInfo {
        @Value("${spring.social.google.client_id}")
        String googleClientId;
        @Value("${spring.social.google.redirect}")
        String googleRedirect;
        @Value("${spring.social.google.client_secret}")
        String googleClientSecret;
        @Value("${spring.social.google.url.token}")
        private String googleTokenUrl;
        @Value("${spring.social.google.url.profile}")
        private String googleProfileUrl;
    }

    @Getter
    @Component
    static class NaverInfo {
        @Value("${spring.social.naver.client_id}")
        String naverClientId;
        @Value("${spring.social.naver.redirect}")
        String naverRedirect;
        @Value("${spring.social.naver.client_secret}")
        String naverClientSecret;
        @Value("${spring.social.naver.url.token}")
        private String naverTokenUrl;
        @Value("${spring.social.naver.url.profile}")
        private String naverProfileUrl;
    }
}
@Getter
@AllArgsConstructor
public class OAuthRequest {
    private String url;
    private LinkedMultiValueMap<String, String> map;
}

마찬가지로 각각의 필요한 값들을 yml에서 불러와 진행하는 소셜 로그인에 맞게 맵을 구성하여 반환한다.

맵을 구성하는 이유는 Access Token을 요청시 x-www-form-urlencoded을 요구한다.

HttpMessageConverter는 LinkedMultivalueMap의 x-www-form-urlencoded 변환을 지원하기에 이렇게 진행하였다.

그리고 토큰을 발급받는 url을 같이 구성할 수 있도록 OAuthRequest 객체를 만들어 url, Map을 담아 반환하였다.

이를 실제로 구성하여 RestTemplate을 이용하여 Post 요청을 보내고 Json을 Gson 라이브러리를 활용하여 객체에 매칭시켜 반환받았다.

실제로 받는 정보들은 다음과 같다.

Access Token을 비롯한 다양한 정보들이 존재한다.

이를 모두 받아온다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccessToken {
    private String access_token;
    private String token_type;
    private String refresh_token;
    private long expires_in;
    private long refresh_token_expires_in;

}

이제 받아온 Access Token을 활용하여 이메일 정보를 받아오도록 한다.

public ProfileDto getProfile(String accessToken, String provider) {
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    httpHeaders.set("Authorization", "Bearer " + accessToken);

    String profileUrl = oAuthRequestFactory.getProfileUrl(provider);
    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(null, httpHeaders);
    ResponseEntity<String> response = restTemplate.postForEntity(profileUrl, request, String.class);

    try {
        if (response.getStatusCode() == HttpStatus.OK) {
            return extractProfile(response, provider);
        }
    } catch (Exception e) {
        throw new CommunicationException();
    }
    throw new CommunicationException();
}

private ProfileDto extractProfile(ResponseEntity<String> response, String provider) {
    if (provider.equals("kakao")) {
        KakaoProfile kakaoProfile = gson.fromJson(response.getBody(), KakaoProfile.class);
        return new ProfileDto(kakaoProfile.getKakao_account().getEmail());
    } else if(provider.equals("google")) {
        GoogleProfile googleProfile = gson.fromJson(response.getBody(), GoogleProfile.class);
        return new ProfileDto(googleProfile.getEmail());
    } else {
        NaverProfile naverProfile = gson.fromJson(response.getBody(), NaverProfile.class);
        return new ProfileDto(naverProfile.getResponse().getEmail());
    }
}

아까와 마찬가지로 request를 만들어 요청하는데 이전과는 다르게 Access Token을 담아 요청을 보낸다.

그렇다면 소셜 플랫폼에 따라 각각의 방식으로 리턴해준다.

각각 오는 형태가 다르기에 다음과 같이 Profile을 따로 만들어 최종적으로 ProfileDto를 구성하여 전달할 수 있도록 하였다.

구글 Profile

@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoogleProfile {
    private String email;
}

카카오 Profile

@Data
@AllArgsConstructor
@NoArgsConstructor
public class KakaoProfile {
    KakaoAccount kakao_account;

    @Data
    public class KakaoAccount {
        private String email;
    }
}

네이버 Profile

@Data
@AllArgsConstructor
@NoArgsConstructor
public class NaverProfile {
    Response response;

    @Data
    public class Response {
        private String email;
    }
}

Profile Dto

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProfileDto {
    private String email;
}

이제 얻은 이메일 정보로 실제로 회원가입 및 로그인을 진행해보자.

@Transactional
public MemberLoginResponseDto loginMemberByProvider(String code, String provider) {
    AccessToken accessToken = providerService.getAccessToken(code, provider);
    ProfileDto profile = providerService.getProfile(accessToken.getAccess_token(), provider);

    Optional<Member> findMember = memberRepository.findByEmailAndProvider(profile.getEmail(), provider);
    if (findMember.isPresent()) {
        Member member = findMember.get();
        member.updateRefreshToken(jwtTokenProvider.createRefreshToken());
        return new MemberLoginResponseDto(member.getId(), jwtTokenProvider.createToken(findMember.get().getEmail()), member.getRefreshToken());
    } else {
        Member saveMember = saveMember(profile, provider);
        saveMember.updateRefreshToken(jwtTokenProvider.createRefreshToken());
        return new MemberLoginResponseDto(saveMember.getId(), jwtTokenProvider.createToken(saveMember.getEmail()), saveMember.getRefreshToken());
    }
}

실제 전체 로직이 보이는 부분이다.

Authentication Code로부터 Access Token을 발급받고 해당 Token으로 Email을 받는다.

Email을 통해 회원가입이 되어있는지를 확인하고 회원가입이 되어있다면 단순 Access Token(JWT), Refresh Token(JWT) 발행.

아니라면, 회원가입을 진행하고 이를 진행한다.

이러면 실제 인증 로직은 끝이 난다.

실제로 이를 확인해보자.

테스트

먼저, 카카오를 통해 로그인하여 얻은 Authentication Code를 넘겨 로그인 API를 호출한다.

그러면 Access Token(JWT), Refresh Token(JWT)를 발급받는다.

그러면 실제 회원가입이 잘 되는 모습을 확인할 수 있다.

그리고 발급받은 Access Token(JWT)을 통해 인증이 필요한 페이지에 접근을 하면 잘 되는 모습을 확인할 수 있다.

마무리

Security를 활용하여 Form Login을 거쳐 REST API를 활용한 OAuth2.0 + 로컬 로그인 With JWT를 구현해보았다.

Security 내용 자체가 처음에 어렵게 느껴지다보니 시간이 오래 소요되었다.

아직 부족한 부분이 존재할 수 있지만 천천히 고쳐나가려고한다.