OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - 앱등록과 OAuth 2.0 기능구현

DevSeoRex·2023년 5월 25일
21
post-thumbnail

😀 앱을 등록해보자!

OAuth 2.0 로그인 기능을 구현하기 위해서는 꽤나 많은 작업이 필요합니다.
이번 파트에서는 네이버, 카카오, 구글에 앱을 등록하고 OAuth 2.0을 본격적으로 사용할 수 있는 준비를 해보겠습니다.

Google 앱 등록하기

  • 프로젝트 선택을 클릭하고, 새 프로젝트를 눌러줍니다.

  • 프로젝트를 생성합니다.

  • 만들어진 프로젝트를 선택하고, API 및 서비스로 이동합니다.

  • 사용자 인증 정보를 클릭하고, 사용자 인증 정보 만들기로 이동합니다.

  • OAuth 클라이언트 ID를 선택해줍니다.

  • 동의 화면 구성하기로 이동합니다.

  • UserType을 설정합니다.

  • OAuth 동의화면에서 앱이름과 사용자 지원 이메일, 개발자 연락처 정보를 등록합니다.

  • 범위 추가 삭제를 클릭하여 정보를 제공할 범위를 설정해줍니다.

  • 다시 대시보드로 돌아가서 OAuth 클라이언트 ID 만들기에 진입해서 필요한 정보를 적어줍니다.

  • 만들어진 클라이언트 ID와 클라이언트 보안 비밀번호를 잘 보관해둡니다.

  • Naver Developers로 이동 후 애플리케이션 이름과 제공 정보를 선택합니다.
  • 서비스 환경을 웹으로 선택하고 URL과 Callback URL을 적어줍니다
  • 앱을 등록하면 나오는 Client ID와 Client Secret을 잘 보관해둡니다.

Kakao 앱 등록하기

  • Kakao Developers로 이동해서 앱을 추가합니다.
  • REST API키를 잘 보관해둡니다.
  • 플랫폼 -> Web 플랫폼 등록으로 이동합니다.
  • Redirect URI를 등록합니다.

  • Kakao 로그인을 활성화 해줍니다.
  • 필요한 동의항목을 체크해줍니다.


앱등록이 모두 마무리 되었습니다! 이제 다음 설정을 해보도록 하겠습니다.

😎 OAuth 2.0 yml 작성

기존에 만들었던 Spring Boot 프로젝트 폴더로 이동합니다.
application.yml 파일을 사용중인데, OAuth 2.0 설정을 전부 같이 넣어서 하게 되면 yml 파일이 굉장히 복잡해집니다. 따라서 application-oauth.yml로 따로 빼서 작성하겠습니다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: {client-id}
            client-secret: {client-secret}
            redirect-uri: "http://localhost:8080/login/oauth2/code/google"
            authorization-grant-type: authorization_code
            scope: email

          kakao:
            client-id: {client-id(Rest Api key)}
            redirect-uri: "http://localhost:8080/login/oauth2/code/kakao"
            client-authentication-method: POST
            authorization-grant-type: authorization_code
            scope: profile_nickname, profile_image, account_email

          naver:
            client-id: {client-id}
            client-secret: {client-secret}
            redirect-uri: "http://localhost:8080/login/oauth2/code/naver"
            authorization-grant-type: authorization_code
            scope: name, email, profile_image
            client-name: Naver

        provider:
          kakao:
            authorization_uri: https://kauth.kakao.com/oauth/authorize
            token_uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user_name_attribute: id
          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response

Spring Boot에서 제공하는 OAuth 2.0 인증 기능에는 naver와 kakao에 대한 설정이 되어 있지 않아서, 직접 작성해주셔야 합니다.

각 서비스 별 scope는 각자 만드는 애플리케이션에서 필요한 정도에 따라서 수정해서 사용하시면 됩니다.
저는 google 설정에서 scope 부분을 빼고 yml을 작성했었는데, 이틀동안 지옥을 맛봤습니다.

꼭! scope 부분 모두 작성해주시길 바라겠습니다 :) 왜 지옥이 열렸는지는 곧 다루겠습니다..

application.yml 이외에 application-{keyword}.yml 파일을 만들어서 설정정보를 분리해 사용한다면, application.yml에 꼭 include 해주어야 합니다!

🧐 OAuth 2.0 기능을 구현해보자!

저번 포스팅에서 SecurityConfig 클래스를 작성하였습니다.
저번 포스팅을 따라하시면서 아직 작성하지 않은 클래스들이 많아서 불편을 겪으셨을거라 생각이 되는데요 :)
지금부터 비어있는 클래스들을 하나하나 작성해보도록 하겠습니다.

CustomOAuth2UserService

CustomOAuth2UserService는 OAuth 2.0 인증을 통해 사용자 정보를 가져오는 역할을 담당합니다.



@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final MemberService memberService;
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 기본 OAuth2UserService 객체 생성
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();

        // OAuth2UserService를 사용하여 OAuth2User 정보를 가져온다.
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);

        // 클라이언트 등록 ID(google, naver, kakao)와 사용자 이름 속성을 가져온다.
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();


        // OAuth2UserService를 사용하여 가져온 OAuth2User 정보로 OAuth2Attribute 객체를 만든다.
        OAuth2Attribute oAuth2Attribute =
                OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // OAuth2Attribute의 속성값들을 Map으로 반환 받는다.
        Map<String, Object> memberAttribute = oAuth2Attribute.convertToMap();

        // 사용자 email(또는 id) 정보를 가져온다.
        String email = (String) memberAttribute.get("email");
        // 이메일로 가입된 회원인지 조회한다.
        Optional<Member> findMember = memberService.findByEmail(email);

        if (findMember.isEmpty()) {
            // 회원이 존재하지 않을경우, memberAttribute의 exist 값을 false로 넣어준다.
            memberAttribute.put("exist", false);
            // 회원의 권한(회원이 존재하지 않으므로 기본권한인 ROLE_USER를 넣어준다), 회원속성, 속성이름을 이용해 DefaultOAuth2User 객체를 생성해 반환한다.
            return new DefaultOAuth2User(
                    Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                    memberAttribute, "email");
        }

        // 회원이 존재할경우, memberAttribute의 exist 값을 true로 넣어준다.
        memberAttribute.put("exist", true);
        // 회원의 권한과, 회원속성, 속성이름을 이용해 DefaultOAuth2User 객체를 생성해 반환한다.
        return new DefaultOAuth2User(
                    Collections.singleton(new SimpleGrantedAuthority("ROLE_".concat(findMember.get().getUserRole()))),
                    memberAttribute, "email");

    }
}

이제 라인별로 코드를 뜯어서 설명해드리겠습니다.

  • 회원 정보를 가져오는 부분

    DefaultOAuth2UserService는 OAuth2UserService<OAuth2UserRequest, OAuth2User> 인터페이스의 구현체입니다.

사용자가 OAuth 인증을 진행하고, 인증에 성공하면 OAuth2UserRequest 객체에 제공자에 대한 정보, 엑세스 토큰 등과 같은 정보를 넣어서 CustomOAuth2UserService로 넘어오게 됩니다.

DefaultOAuth2UserService에 액세스 토큰과 제공자에 대한 정보를 가지고 있는 OAuth2UserRequest를 전달하게 되면, 내부적으로 액세스 토큰을 꺼내 사용자 정보를 리소스 서버에 요청하고 응답받게 됩니다.

여기서 응답받은 사용자의 정보들은 OAuth2User 클래스에 매핑되게 됩니다.

  • OAuth2Attribute 클래스를 만드는 부분

제가 만드는 애플리케이션에서는 어떤 플랫폼에서 OAuth 인증을 했는지 정보가 필요해서 ClientRegistration 객체에서 Id 값을 뽑아왔습니다.

이제 OAuth2Attribute 클래스가 나오는데, 이 클래스는 OAuth 인증을 통해 얻어온 사용자의 정보와 속성들(이메일, 프로필 사진, 닉네임 등)을 Map 형태로 반환받기 위해 사용하는 빌더 클래스입니다.


@ToString
@Builder(access = AccessLevel.PRIVATE) // Builder 메서드를 외부에서 사용하지 않으므로, Private 제어자로 지정
@Getter
public class OAuth2Attribute {
    private Map<String, Object> attributes; // 사용자 속성 정보를 담는 Map
    private String attributeKey; // 사용자 속성의 키 값
    private String email; // 이메일 정보
    private String name; // 이름 정보
    private String picture; // 프로필 사진 정보
    private String provider; // 제공자 정보

    // 서비스에 따라 OAuth2Attribute 객체를 생성하는 메서드
    static OAuth2Attribute of(String provider, String attributeKey,
                              Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return ofGoogle(provider, attributeKey, attributes);
            case "kakao":
                return ofKakao(provider,"email", attributes);
            case "naver":
                return ofNaver(provider, "id", attributes);
            default:
                throw new RuntimeException();
        }
    }

    /*
    *   Google 로그인일 경우 사용하는 메서드, 사용자 정보가 따로 Wrapping 되지 않고 제공되어,
    *   바로 get() 메서드로 접근이 가능하다.
    * */
    private static OAuth2Attribute ofGoogle(String provider, String attributeKey,
                                            Map<String, Object> attributes) {
        return OAuth2Attribute.builder()
                .email((String) attributes.get("email"))
                .provider(provider)
                .attributes(attributes)
                .attributeKey(attributeKey)
                .build();
    }

    /*
     *   Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서,
     *   두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
     * */
    private static OAuth2Attribute ofKakao(String provider, String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuth2Attribute.builder()
                .email((String) kakaoAccount.get("email"))
                .provider(provider)
                .attributes(kakaoAccount)
                .attributeKey(attributeKey)
                .build();
    }

    /*
    *  Naver 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 response Map에 감싸져 있어서,
    *  한번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
    * */
    private static OAuth2Attribute ofNaver(String provider, String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuth2Attribute.builder()
                .email((String) response.get("email"))
                .attributes(response)
                .provider(provider)
                .attributeKey(attributeKey)
                .build();
    }


    // OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환해준다.
    Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("id", attributeKey);
        map.put("key", attributeKey);
        map.put("email", email);
        map.put("provider", provider);

        return map;
    }
}

여기서도 중요 부분을 뜯어서 설명해드리겠습니다.

of 메서드는 서비스(구글, 카카오, 네이버)에 따라서 값을 빼오는 방식이 다르기 때문에 switch문을 통한 분기를 통해서 사용할 메서드를 정하고 OAuth2Attribute 객체를 반환합니다.

OAuth2Attribute 객체에 가지고 있는 내부 속성들을 Map으로 변환해서 반환하는 메서드를 이용해 OAuth2User의 속성값들을 채워줄때 사용합니다.

다시 CustomOAuth2UserService로 돌아오겠습니다.

  • 받아온 사용자의 정보로 가입 여부에 따라 다른 OAuth2User 객체를 반환

    먼저 사용자의 이메일로 현재 존재하는 회원인지 조회합니다.
    현재 가입된 회원이라면, SuccessHandler에서 jwt 토큰을 발급해서 프론트에 넘겨주게 되고 가입되지 않은 회원이라면 프론트의 회원가입 URL로 보내지게됩니다.

DefaultOAuth2User는 OAuth2User의 구현체입니다. 조회한 회원이 가진 권한과, 속성들을 넣어서 반환해주게 됩니다. 이때 exist 라는 변수를 회원의 속성에 넣어주는 것을 보실 수 있습니다.

SuccessHandler에서 exist 변수의 값에 따라서 회원가입을 했는지 안했는지 여부를 확인하고 처리할 수 있기 때문에 넣어주었습니다.

현재 가입하지 않은 회원의 경우에는 권한이 데이터베이스에 존재하지 않기 때문에 따로 직접 USER 권한을 넣어주었습니다.

회원가입이 되지 않은 상태이기 때문에 권한을 넣어서 객체를 반환해도, 회원가입 페이지로 자동 리디렉션 됩니다.

MyAuthenticationSuccessHandler

MyAuthenticationSuccessHandler는 OAuth2 인증이 성공했을 경우, 성공 후 처리를 위한 클래스입니다.


@Slf4j
@Component
@RequiredArgsConstructor
public class MyAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        // OAuth2User로 캐스팅하여 인증된 사용자 정보를 가져온다.
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        // 사용자 이메일을 가져온다.
        String email = oAuth2User.getAttribute("email");
        // 서비스 제공 플랫폼(GOOGLE, KAKAO, NAVER)이 어디인지 가져온다.
        String provider = oAuth2User.getAttribute("provider");

        // CustomOAuth2UserService에서 셋팅한 로그인한 회원 존재 여부를 가져온다.
        boolean isExist = oAuth2User.getAttribute("exist");
        // OAuth2User로 부터 Role을 얻어온다.
        String role = oAuth2User.getAuthorities().stream().
                findFirst() // 첫번째 Role을 찾아온다.
                .orElseThrow(IllegalAccessError::new) // 존재하지 않을 시 예외를 던진다.
                .getAuthority(); // Role을 가져온다.

        // 회원이 존재할경우
        if (isExist) {
            // 회원이 존재하면 jwt token 발행을 시작한다.
            GeneratedToken token = jwtUtil.generateToken(email, role);
            log.info("jwtToken = {}", token.getAccessToken());

            // accessToken을 쿼리스트링에 담는 url을 만들어준다.
           String targetUrl = UriComponentsBuilder.fromUriString("http://3.39.72.204/loginSuccess")
                    .queryParam("accessToken", token.getAccessToken())
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
           log.info("redirect 준비");
           // 로그인 확인 페이지로 리다이렉트 시킨다.
           getRedirectStrategy().sendRedirect(request, response, targetUrl);


        } else {

            // 회원이 존재하지 않을경우, 서비스 제공자와 email을 쿼리스트링으로 전달하는 url을 만들어준다.
            String targetUrl = UriComponentsBuilder.fromUriString("http://3.39.72.204/loginSuccess")
                    .queryParam("email", (String) oAuth2User.getAttribute("email"))
                    .queryParam("provider", provider)
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
            // 회원가입 페이지로 리다이렉트 시킨다.
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        }
    }

}

한줄씩 뜯어서 코드를 살펴 보겠습니다.

  • CustomOAuth2UserService에서 넘어온 OAuth2User 객체로 로직을 처리

Authentication 안에 들어있는 OAuth2User를 꺼내서, 이메일과 서비스 제공 플랫폼을 가져와 변수에 저장합니다.
CustomOAuth2UserService 에서 Map에 넣어준 exist 변수를 꺼내서 변수에 저장합니다.

JWT 토큰을 발급할때 필요한 권한을 OAuth2User로부터 가져옵니다.
OAuth2User의 Authorities는 Collection으로 구성되어 있는데, 현재 만드는 애플리케이션에서는 권한은 하나만 가지도록 설계되어 있으므로, 가장 첫번째 권한만 찾도록 하였습니다.

  • 회원이 존재할 경우 액세스 토큰을 포함하여 프론트 로그인 성공 URL로 리디렉션

    회원이 존재할경우 회원의 이메일과 권한을 이용해 JWT 토큰을 발행합니다.
    액세스 토큰을 쿼리 파라미터에 추가하고, 프론트의 로그인 성공 URL로 리디렉션 시킵니다.

  • 회원이 존재하지 않을 경우, 서비스 제공자와 email을 포함하여 회원가입 페이지로 리디렉션

회원이 존재하지 않을 경우, 서비스 제공자와 email을 쿼리 파라미터에 추가하고 프론트의 회원가입 페이지로 리디렉션 시킵니다.

MyAuthenticationFailureHandler

MyAuthenticationFailureHandler는 정말 단순하게 프론트의 홈으로 redirect 되도록 작성하였습니다.

@Component
@Slf4j
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 인증 실패시 메인 페이지로 이동
        response.sendRedirect("http://localhost:8080/");
    }

}

이 코드는 한줄뿐이라 따로 설명하지는 않겠습니다.

🥺 간단한 Trouble Shooting!

아까 위에서 말씀 드렸던 것 처럼 yml 파일 설정에서 scope를 깜빡하고 작성해주지 않으면 아주 큰일이 난다고 말씀 드렸었는데요.

제가 그것때문에 꽤나 긴 시간을 낭비해서 많은 고통을 받았습니다.
scope를 기입하지 않으면, OAuth2User 객체의 값이 매핑되지 않습니다. 내부적으로 scope 값을 읽어서 사용하는 부분이 있는 것 같습니다.

🧑‍💻 거의 다왔다..!!

세개의 포스팅을 거쳐서 OAuth 2.0과 JWT에 대한 개념 Spring Security 설정과 OAuth 2.0 인증관련 클래스들을 작성했습니다.

다음 포스팅부터는 JWT 토큰 발급을 위한 로직과 Refresh Token을 관리하기 위한 Redis 설정 및 사용에 대해서 포스팅 하겠습니다.

오늘도 함께해주셔서 감사합니다.

다음 시리즈 게시물로 이동
OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - Access Token & Refresh Token 생성
게시물로 이동 ->

🙇

6개의 댓글

comment-user-thumbnail
2024년 3월 6일

글 잘 읽었습니다~ 보면서 궁금한게 존재하는 회원일때 jwt토큰을 쿼리파라미터로 주면 위험하지 않나요?

1개의 답글
comment-user-thumbnail
2024년 8월 3일

안녕하세요 글 잘읽고 따라하고 있습니다!
다름이 아니라 Role에서 궁금증을 느끼는데, 제가 권한을 따로 맴버에 만들지 않다가
따라하면서 만드는데,
// OAuth2User로 부터 Role을 얻어온다.
String role = oAuth2User.getAuthorities().stream().
findFirst() // 첫번째 Role을 찾아온다.
.orElseThrow(IllegalAccessError::new) // 존재하지 않을 시 예외를 던진다.
.getAuthority(); // Role을 가져온다.

여기서 제가 평소에 Role를 Member에게 주지 않았으면 문제가 생기나요?
혹시 member에게 어떤 Role을 언제 주시는지 알 수 있을까여?

1개의 답글