📡 백엔드/🌱 Spring Boot

[Spring] OAuth2Service + 스프링 시큐리티 + JWT로 카카오 로그인 구현하기

gengminy 2022. 8. 27. 00:53

📌 OAuth

"OpenID Authorization"의 약자

비밀번호를 제공하지 않으면서 웹사이트나 어플리케이션 접근 권한을 부여할 수 있는 로그인 방식

기존 아이디와 비밀번호를 통한 로그인 방식은 보안상 취약한 점이 아주 많다

그러나 OAuth 를 사용하면 특정 접근 권한만 부여할 수도 있고

강력한 보안을 제공하는 대기업에 사용자 인증과 인가를 위임하는 방식으로 안전하게 로그인할 수 있다

 

 

🏛 Dependency

implementation group: 'org.springframework.security', name: 'spring-security-oauth2-client', version: '5.6.3'

build.gradle 에 OAuth 관련 의존성을 추가해준다

 

 

🚀 Kakao Developers 설정

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

시작하기

 

어플리케이션 추가하기 누르고 앱 이름과 사업자명 입력

이후 만든 어플리케이션 클릭해서 이동

 

 

REST API 키 나중에 사용해야 하니까 이 페이지 기억해두기

 

좌측 메뉴 카카오 로그인 -> 활성화 설정 상태 ON 으로 변경

이후 아래 Redirect URI 설정으로 진입

 

일단 로컬 환경에서 테스트를 진행해야 하기 때문에 로컬호스트로 설정

로그인 시도할 때 이 URI 를 통해 인가 코드가 반환이 된다

이 URI 은 클라이언트 쪽에서 사용해야 하니까 잘 기억해두기

 

 

카카오 로그인 -> 동의항목 에서

OAuth 를 통한 회원가입 진행 시 받아올 정보를 설정

 

 

 

💻 application.yml 설정

📝 application.yml

...
...
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_RESTAPI_KEY}
            redirect-uri: ${KAKAO_REDIRECT_URI}
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Kakao
            scope:
              - profile_nickname
              - account_email
        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
...
...

설정 파일에서 security 부분을 추가해준다

아까 받은 rest api key 와 redirect uri 를 넣어주면 된다

 

 

💻 OAuth2 구현

📝 CustomOAuth2Service

@Slf4j
@RequiredArgsConstructor
@Service
public class CustomOAuth2Service extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        //provider 정보 (kakao, google, naver...)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        log.info("registrationId = {}", registrationId);
        log.info("userNameAttributeName = {}", userNameAttributeName);

        //provider 정보 기반 객체 생성
        OAuth2Attribute oAuth2Attribute =
                OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        final String email = oAuth2Attribute.getEmail();
        final String name = oAuth2Attribute.getName();
        final String provider = oAuth2Attribute.getProvider();

        if (userRepository.existsByEmail(email)) {
            log.info("가입된 회원입니다");
        } else {
            User user = User.builder()
                    .email(email)
                    .name(name)
                    .provider(provider)
                    .role(UserRole.ROLE_USER)
                    .build();

            userRepository.save(user);

            log.info("회원가입");
        }

        var memberAttribute = oAuth2Attribute.convertToMap();

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                memberAttribute, "email");
    }
}

DefaultOAuth2UserService 를 구현한 클래스이다

이 클래스는 사용자 정보 기반으로 여러 기능을 지원해준다

 

OAuth2UserRequest 는 서드파티 서버로부터 사용자 정보를 받아올 수 있다

이 정보를 통해 가입된 회원인지 아닌지 파악하고 결과를 반환해주면 된다

 

결과로는 UserPrincipal 을 반환하는데 세션 방식에서는 이 결과가 저장이 되지만

우리는 JWT 방식을 사용하기 때문에 무시된다

 

 

📝 OAuth2Attribute

@ToString
@Builder(access = AccessLevel.PRIVATE)
@Getter
public class OAuth2Attribute {
    private Map<String, Object> attributes;
    private String attributeKey;
    private String email;
    private String name;
    private String provider;

    static OAuth2Attribute of(String provider, String attributeKey,
                              Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return ofGoogle(attributeKey, attributes);
            case "kakao":
                return ofKakao("email", attributes);
            case "naver":
                return ofNaver("id", attributes);
            default:
                throw new RuntimeException();
        }
    }

    private static OAuth2Attribute ofGoogle(String attributeKey,
                                            Map<String, Object> attributes) {
        return OAuth2Attribute.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .provider("google")
                .attributes(attributes)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuth2Attribute ofKakao(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()
                .name((String) kakaoProfile.get("nickname"))
                .email((String) kakaoAccount.get("email"))
                .provider("kakao")
                .attributes(kakaoAccount)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuth2Attribute ofNaver(String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuth2Attribute.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .provider("naver")
//                .picture((String) response.get("profile_image"))
                .attributes(response)
                .attributeKey(attributeKey)
                .build();
    }

    Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("id", attributeKey);
        map.put("key", attributeKey);
        map.put("email", email);
        map.put("name", name);
        map.put("provider", provider);

        return map;
    }
}

OAuth 를 지원하는 플랫폼 마다 이름들이 조금씩 달라져서

통합하여 관리하기 위한 클래스이다

provider에 따라서 자동으로 이름을 변환시켜서 값을 대입해준다

카카오 뿐 아니라 다른 플랫폼으로도 확장하기 위하여 도입시켰다

 

 

📝 OAuth2AuthenticationSuccessHandler

package Backend.HIFI.auth.oauth;

import Backend.HIFI.auth.dto.TokenResponseDto;
import Backend.HIFI.auth.jwt.JwtTokenProvider;
import Backend.HIFI.auth.security.UserAuthentication;
import Backend.HIFI.user.User;
import Backend.HIFI.user.UserRepository;
import Backend.HIFI.user.UserRole;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.stream.Collectors;

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

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        //로그인 성공한 사용자
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();

        final String email = oAuth2User.getName();
        final String provider = oAuth2User.getAttribute("provider");
        final String name = oAuth2User.getAttribute("name");
        final String authorities = oAuth2User.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        TokenResponseDto tokenResponseDto = jwtTokenProvider.generateOAuth2Token(oAuth2User);
        log.info("토큰 발행");

        String targetUrl = UriComponentsBuilder.fromUriString("/oauth2/redirect")
                        .queryParam("token", tokenResponseDto.getAccessToken())
                                .build().toUriString();
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

CustomOAuth2Service 이후로 실행되는 핸들러 메서드

여기서 엑세스 토큰을 만들어서 이것을 쿼리 파라미터로 리디렉션하여 프론트에 넘겨주게 된다

프론트에서는 이 값을 저장하면 된다

 

리프레시 토큰을 Redis 에 저장하는 로직은 아직 추가 안했는데

더 공부하면서 추가할 것이다

 

http://localhost:8000/oauth2/redirect?token={accessToken}

 

 

📝 SecurityConfiguration

public class SecurityConfiguration {
    ...
    ...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                ...
                ...
                .oauth2Login()
                .defaultSuccessUrl("/login-success")
                .successHandler(oAuth2AuthenticationSuccessHandler)
                .userInfoEndpoint()
                .userService(customOAuth2Service);

                http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
                return http.build();
    }
    ...
    ...
}

마지막으로 스프링 시큐리티 필터 체인에 등록시켜주면 끝난다

  • defaultSuccessUrl - 로그인 성공 시 이동할 url
  • successHanlder - 인증 절차에 따라서 사용자가 정의한 로직을 실행시킴
  • userInfoEndpoint - OAuth 로그인 성공 이후 행동 정의
  • userService - customOAuth2Service 에서 후처리 하겠다고 선언

 

 

http://localhost:{서버포트}/oauth2/authorization/kakao 로 접속하면 카카오 로그인 화면이 뜬다

절차를 동의하고 실행하면 리디렉션이 된다

 

 

📚 Reference