응애개발자
article thumbnail
728x90

이번 시간은 Kakao 로그인이다.

🤔 왜 OAUTH2를 진행했나?

OAUTH2를 사용하는 또다른 중요한 이유는 회원에 대한 중요한 개인정보(ex. 계정과 계정에 대한 비밀번호, 기타 개인정보)를 내 DB에 저장하지 않음으로써 혹시라도 노출될 사고를 줄일 수 있다는 점때문에 사용하였다. 결론적으로 사용자와 개발자 모두에게 편의성과 보안성을 제공할 수 있기 때문에 진행하게 되었다.

 

 

📌 Spring Security 동작 원리

모든 책임을 백엔드가 맡음

필자는 소셜 로그인을 백엔드로만 진행했다. 보통 백엔드와 프론트엔드를 섞어서 프론트에서 코드를 받거나 토큰을 발급받는 경우가 있는데 카카오와 같은 대형 서비스 개발 포럼 및 보안 규격에서는 책임을 나누어 받는 것을 지양한다. 따라서 모든 책임을 프론트에서 맡거나, 모든 책임을 백엔드에서 맡아야 하는데 필자는 모든 책임을 백엔드에서 맡는 방법으로 구현하였다.

 

동작 순서를 간단히 보자면

 

  1. 클라이언트가 소셜 로그인 버튼을 클릭한다.
  2. 백엔드로 하이퍼링크 요청이 날라온다.
  3. 백엔드에서 카카오 로그인 페이지(카카오 인증서 버)로 리다이렉트 시켜준다.
  4. 클라이언트가 로그인을 시도한다.
  5. 카카오 인증서버에서 로그인이 성공하면 뒤에 code = ??? 이런식으로 인증 코드를 발급해준다.
  6. 이 인증 코드를 갖고 설정한 개인정보를 볼 수 있는 엑세스 토큰(카카오에서 발급해주는 엑세스 토큰)을 요청한다.
  7. 엑세스 토큰을 인증서버에서 발급받는다.
  8. 이 엑세스 토큰으로 유저에 대한 정보를 요청한다.
  9. 유저 정보를 리소스 서버에서 발급받는다.
  10. 유저 정보를 확인하고 우리 서버에 회원가입이 되어있는지 확인하고 안되어 있으면 우리 DB에 정보를 저장하고(중요한 정보 보단 사용자의 이메일이나, 프로필 사진 정도) 우리 서버를 이용할 수 있는 AccessToken, RefreshToken을 발급해주고, 되어 있다면 마찬가지로 Access,Refresh 토큰을 발급해준다.

 

 

위 과정을 진행하기 위해  먼저 카카오 로그인을 하기 위해서는 카카오 디벨로퍼에 내 어플리케이션을 등록한다.

 

애플리케이션 추가하기

 

애플리케이션을 등록한뒤 비즈앱을 신청해야 한다. 비즈앱을 신청해야 카카오 간편가입을 사용할 수 있다.

 

비즈앱 신청하기

 

 

이렇게 신청 자격을 휙득하고

 

개인정보 동의학목 심사 신청

 

개인정보 동의항목 심사 신청을 한 뒤 밑에서 카카오 로그인 후에 얻어야 할 정보들 필자는 닉네임, 프로필 사진, 이메일이 필요해서 다 필수 동의로 진행했다.

 

카카오 로그인 활성화

그러고 난 뒤 카카오 로그인을 진행할 것이기 때문에 카카오 로그인 활성화를 ON 해준다.

 

 

플랫폼 사이트 도메인 등록

그러고 난 뒤 사이트 도메인을 등록한다. 사이트 도메인을 등록함으로써 Redirect URI만 등록할 경우 사전에 등록되지 않은 도메인으로의 리디렉션 요청은 거부할 수 있기 때문에 도메인 등록은 꼭 해주어야 한다.

 

 

Redirect URI 설정

 

Redirct URI를 설정한다. 여기서 Redirect URI는 내가 받을 서버의 주소를 말한다. 

 

백엔드 구현

구현 순서

  1. application.yml 설정
  2. SecurityConfig 설정
  3. CustomOAuthUserService 구현
  4. OAuth2Response 구현
  5. KakaoResponse 구현
  6. CustomOAuth2User 구현
  7. CustomSuccessHandler구현

그러면 스프링으로 돌아가서 로직을 구현해보자. 

 

1. application.yml 설정

 

registration - kakao

  • Client-name : 설정 파일 내에서 클라이언트를 구분하는 데 사용 (Naver는 client-name 이 naver이다.) 이건 자유롭게 지정 가능 (Kakao를 kak 이런식으로 지정해도 상관 x)
  • Client-id : 앱 설정 -> 앱 키 - > Rest API 키
  • Client-secret : 제품 설정 -> 카카오 로그인 -> 보안 -> Clinet Secret
  • redirect-uri : 인가 코드와 엑세스 토큰을 받을 내 주소
  • authorization-grant-type : authorization_code  권한을 수여받을 타입을 지정
  • scope : 내가 받고 싶은 권한 (위에서 이메일,닉네임,프로필 이미지를 받고 싶으므로)

provider - kakao

  • authorization-uri : 카카오에서 설정한 로그인 폼을 보여줄 즉 authorization_code를 받을 페이지
  • token-uri : 카카오에서 설정한 토큰을 받을 페이지
  • user-info-uri : 유저 정보를 받을 페이지
  • user-name-attribute :  카카오에서는 요청 정보를 id 키에 담아서 리턴한다. 그래서 id 로 설정

구현에 들어가기 앞서 이 정보들이 어떻게 쓰이는지 정리해보겠다.

  1. 프론트에서 로그인 버튼 클릭
  2. SpringSecurity가 이 요청을 가로채서 https://kauth.kakao.com/oauth/authorize로 인가 코드 요청을 보낸다. 이때 client-id와 redirect-uri, 응답 유형 등의 정보가 같이 넘어간다.(카카오 에서도 어떤 앱인지 알아야 함으로)
  3. 사용자는 이메일과 비밀번호를 입력하여 로그인한다.
  4. 로그인이 성공하면 카카오에서 설정한 redirect-uri와 yml에서 설정한 redirect-uri 즉 내 서버로 요청을 받기 위해 redirect-uri로 인가 코드를 반환받는다.
  5. SpringSecurity는 인가 코드를 받아서 https://kauth.kakao.com/oauth/token 으로 엑세스 토큰 요청을 자동으로 보낸다. 이때 clinet-id와 client-secret, 엑세스 토큰(인가 코드), redirect-uri등이 포함된다. client-secret이 있는 이유는 내 애플리이션에서 카카오쪽으로 요청을 보내는게 맞는지 확인하기 위해서이다. (애플리케이션의 신원을 확인)
  6. 엑세스 토큰을 받은 후 SpringSecurity는 https://kapi.kakao.com/v2/user/me 로 받은 엑세스 토큰을 이용하여 사용자 정보를 받아온다.

 

2. SecurityConfig 설정

@Configuration
@EnableWebSecurity // security 활성화 어노테이션
@RequiredArgsConstructor
public class SecurityConfig {
    private final CorsConfigurationSource corsConfigurationSource;

    //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;
    //AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{


        http
                // CSRF 비활성화 (token을 사용하는 방식이기 때문에 csrf.token이 필요 없음)
                .csrf(CsrfConfigurer::disable)
                //시큐리티 폼 로그인 방식 사용 x
                .formLogin(auth -> auth.disable())
                //httpBasic방식 사용 x -> Bearer 방식
                .httpBasic(auth -> auth.disable())
                //oauth2
                .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService)).successHandler(customSuccessHandler))
                // corsConfig 사용
                .cors(cors -> cors.configurationSource(corsConfigurationSource))
                //세션 사용 하지 않음(ALWAYS, IF_REQUIRED, NEVER등)
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth.dispatcherTypeMatchers().permitAll()
                        .requestMatchers("/login").permitAll()
                        .requestMatchers("/api/v1/refresh-token").permitAll()
                        .requestMatchers("/api/v1/users").permitAll()
                        .requestMatchers("/api/v1/users/email-check").permitAll()
                        .requestMatchers("/api/v1/users/**").hasAuthority("USER")
                        .anyRequest().authenticated()) // 허가된 사람만 인가
                .addFilterAt(new JWTFilter(jwtUtil),LoginFilter.class)
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, tokenRepository), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, tokenRepository), LogoutFilter.class);

        return http.build();

    }
}

 

사실 이전 글에서 달라진건 oauth2Login 설정이 들어간 것 뿐이다.

 

3. OAuthUserService 설정

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        System.out.println(oAuth2User);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;
        if (registrationId.equals("kakao")) {
            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        }

        else {

            return null;
        }

        //db에 유저가 있는지 판단
        Optional<User> byUser = userRepository.findByEmail(oAuth2Response.getEmail());

        //회원가입 유저 없으면 (이부분 수정)
        if(byUser.isEmpty()){
            String username = oAuth2Response.getProvider()+" "+oAuth2Response.getName();

            User user = new User(SocialType.KAKAO,oAuth2Response.getEmail(),username,Role.USER,oAuth2Response.getProfileImage());

            userRepository.save(user);
            return new CustomOAuth2User(user);
        }else{
            //회원가입 유저 있으면 로그인 진행
            User user = byUser.get();
            return new CustomOAuth2User(user);
        }
    }
}

이제 Security와 OAuth로 카카오 요청이 성공적으로 마치고 난다면 CustomOAuth2UserService로 요청이 들어오게 될 것이다. 여기서 이제 내 DB에 회원 정보가 없으면 회원 정보를 넣어주고, 회원 가입된 유저가 있으면 OAuth2Response에 담아서 성공적으로 로그인 해주기 위해 return해준다.

4. OAuth2Response 설정

public interface OAuth2Response {
    //제공자 (ex. naver, kakao)
    String getProvider();
    //제공자에서 발급해주는 아이디(번호)
    String getProviderId();
    //이메일
    String getEmail();
    //이름
    String getName();
    //사진
    String getProfileImage();
}

5. KakaoResponse 설정

@RequiredArgsConstructor
public class KakaoResponse implements OAuth2Response{

    private final Map<String, Object> attributes;
    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attributes.get("id").toString();
    }

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        return kakaoAccount.get("email").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
        return properties.get("nickname").toString();
    }

    @Override
    public String getProfileImage() {
        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
        return properties.get("profile_image").toString();
    }
}

 

6. CustomOAuth2User 설정

@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {

    private final User user;
    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {

            @Override
            public String getAuthority() {

                return user.getRole().getName();
            }
        });

        return collection;
    }

    public User getUser(){ return user;}

    public Long getUserId(){ return user.getId();}
    @Override
    public String getName() {
        return user.getEmail();
    }

    public String getNickname(){
        return user.getNickname();
    }
}

 

7. CustomSuccessHandler 설정

@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;

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

        //OAuth2User
        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

        User user = customUserDetails.getUser();
        Long userId = customUserDetails.getUserId();
        String username = customUserDetails.getName();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();

        //유효기간 30분
        String accessToken = jwtUtil.createAccessToken("accessToken", userId, role,30*60*1000L);
        //유효기간 30일
        String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, 30*24*60*60*1000L);


        Token refreshEntity = Token.toEntity(user, refreshToken,  LocalDateTime.now().plusDays(30));
        tokenRepository.save(refreshEntity);

        response.addCookie(createCookie("accessToken", accessToken));
        response.addCookie(createCookie("refreshToken", refreshToken));
        response.sendRedirect("http://localhost:3000/");
    }

    private Cookie createCookie(String key, String value){
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60);
//        cookie.setSecure(true);
//        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }

}

카카오 로그인이 성공한다면 CustomSuccessHandler로 와서 토큰을 만들어서 유저에게 반환을 해주는 것으로 마무리 됩니다. 

 

혹시 진행하다가 궁금한게 있으시면 댓글로 남겨주세요! 성심성의껏 답변드리겠습니다.

profile

응애개발자

@Eungae-D

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!