[프로젝트] Spring Security + OAuth + JWT + Redis를 활용한 로그인 및 회원가입 구현 (12) - OAuth 2.0 로그인 구현 (Google, Kakao, Naver) 후편

김찬미·2024년 7월 26일
0
post-thumbnail

프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git

⌨️ 전편에 이어

[프로젝트] Spring Security + OAuth + JWT + Redis를 활용한 로그인 및 회원가입 구현 (11) - OAuth 2.0 로그인 구현 (Google, Kakao, Naver) 전편

OAuth 로그인이 관련된 클래스들이 많은 만큼, 게시물이 너무 길어져 전편과 후편으로 나누었다. 전편에서는 SecurityConfig, User 등 기존에 있던 클래스에서 추가된 내용이나 CustomOAuth2User, CustomOAuth2UserService에 대해 다루었으니 참고 바란다.


🗂️ 패키지 구조

com.project.securelogin
├── config
│   └── RedisConfig.java
│   └── SecurityConfig.java
├── controller
│   └── AuthController.java
│   └── UserController.java
├── domain
│   └── CustomOAuth2User.java
│   └── CustomUserDetails.java
│   └── User.java
├── dto
│   └── JsonResponse.java
│   └── UserRequestDTO.java
│   └── UserResponseDTO.java
├── exception
│   └── UserAccountLockedException.java
│   └── UserNotEnabledException.java
├── jwt
│   └── JwtAuthenticationFilter.java
│   └── JwtTokenProvider.java
├── oauth ☑️
│   └── handler
│   	└── OAuth2LoginFailureHandler.java ✔️
│   	└── OAuth2LoginSuccessHandler.java ✔️
│   └── info
│   	└── OAuth2UserInfo.java ✔️
│   	└── GoogleOAuth2UserInfo.java ✔️
│   	└── KakaoOAuth2UserInfo.java ✔️
│   	└── NaverOAuth2UserInfo.java ✔️
│   	└── OAuth2UserInfoFactory.java ✔️
├── repository
│   └── JwtTokenRedisRepository.java
│   └── UserRepository.java
└── service
    └── AuthService.java
    └── CustomOAuth2UserService.java
    └── CustomUserDetailsService.java
    └── MailService.java
    └── UserService.java

✔️ 변경된 클래스

  • OAuth2LoginFailureHandler.java & OAuth2LoginSuccessHandler.java

    • OAuth2 로그인 성공 및 실패 핸들러 구현
  • OAuth2UserInfo 클래스들

    • GoogleOAuth2UserInfo, KakaoOAuth2UserInfo, NaverOAuth2UserInfo
    • 각 소셜 플랫폼별 사용자 정보 추출 클래스 추가
  • OAuth2UserInfoFactory.java

    • 소셜 플랫폼에 따라 적절한 OAuth2UserInfo 객체를 생성하는 팩토리 클래스 추가

이번 게시물에서는 바로 이곳, oauth 패키지 안 클래스들에 대해 설명하고 최종적인 테스트 방법과 결과를 서술할 것이다. 이제 구현해 보자.


OAuth2UserInfo

OAuth2UserInfo 클래스는 OAuth 2.0 인증을 통해 얻은 사용자 정보를 추상화한 클래스로, 다양한 소셜 로그인 제공자(Google, Kakao, Naver 등)에서 사용자 정보를 통일된 형태로 다루기 위해 설계되었다.

이 클래스는 각 소셜 로그인 제공자에 대한 구체적인 구현 클래스들이 공통적으로 상속받아 사용하는 기본 구조를 정의한다.

public abstract class OAuth2UserInfo {
    protected Map<String, Object> attributes;

    public OAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    public abstract String getId();
    public abstract String getName();
    public abstract String getEmail();
}

✅ 역할

  • 추상화:
    • OAuth2 로그인 제공자별로 받은 사용자 정보를 공통된 형식으로 추상화한다.
  • 사용자 정보 구조 처리:
    • 소셜 로그인 제공자에 따라 다른 사용자 정보 구조를 처리하기 위해 상속받아 사용할 수 있다.
  • 메서드 정의
    • 사용자 정보(id, name, email)를 각 제공자별로 적절히 추출하여 제공하는 메서드를 정의한다.

✅ 구성 요소

1) protected Map<String, Object> attributes

  • 타입: Map<String, Object>
  • 설명:
    • 소셜 로그인 제공자로부터 전달받은 사용자 정보를 담고 있는 맵
    • 이 맵의 구조와 키는 제공자에 따라 다를 수 있다.
    • 각 제공자에 맞는 정보 추출 로직은 이 클래스의 하위 클래스에서 정의된다.

2) public OAuth2UserInfo(Map<String, Object> attributes)

  • 설명: 생성자 메서드로, 소셜 로그인 제공자로부터 받은 사용자 정보를 attributes 매개변수로 받아 attributes 필드를 초기화한다. 하위 클래스는 이 생성자를 통해 전달된 정보를 기반으로 사용자 정보를 처리한다.

3) public abstract String get❓()

  • 설명: 추상 메서드로, 각각 사용자 ID, 이름, 이메일을 반환한다. 각 소셜 로그인 제공자에 따라 사용자 ID를 추출하는 방식이 다르기 때문에, 이 메서드는 하위 클래스에서 구현되어야 한다.

각 소셜 로그인 제공자(Google, Kakao, Naver)에서 전달받은 사용자 정보를 처리하기 위해, OAuth2UserInfo 클래스를 상속받아 구체적인 사용자 정보 추출 로직을 구현하는 하위 클래스를 정의하여 코드를 작성한다.

  • GoogleOAuth2UserInfo
  • KakaoOAuth2UserInfo
  • NaverOAuth2UserInfo

이러한 하위 클래스들은 OAuth2UserInfo의 추상 메서드를 구현하여 각 제공자에 맞는 사용자 정보를 추출하고 반환한다.


GoogleOAuth2UserInfo

public class GoogleOAuth2UserInfo extends OAuth2UserInfo {

    public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }
}

🔵GoogleResponse 형태

{
  "sub": "1234567890",
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "picture": "https://lh3.googleusercontent.com/a-/AOh14GExample",
  "email": "johndoe@example.com",
  "email_verified": true,
  "locale": "en"
}

저번 게시물에서도 설명했지만, 이해를 돕기 위해 다시 한 번 Json 응답 예시를 가져와 보았다. 여기서 subid를 말한다.

Google같은 경우 모두 바깥에 위치했기 때문에, attributes에서 바로 get()으로 각 정보를 가져오면 된다.


NaverOAuth2UserInfo

public class NaverOAuth2UserInfo extends OAuth2UserInfo {

    public NaverOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        // 네이버 응답에서 'response' 속성 내부의 'id' 값을 추출
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return (String) response.get("id");
    }

    @Override
    public String getName() {
        // 네이버 응답에서 'response' 속성 내부의 'name' 값을 추출
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return (String) response.get("name");
    }

    @Override
    public String getEmail() {
        // 네이버 응답에서 'response' 속성 내부의 'email' 값을 추출
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return (String) response.get("email");
    }
}

🟢 NaverResponse 형태

{
  "resultcode": "00",
  "message": "success",
  "response": {
    "id": "1234567890abcdefg",
    "nickname": "홍길동",
    "profile_image": "https://example.com/profile.jpg",
    "email": "johndoe@example.com",
    "name": "홍길동",
    "age": "20-29",
    "gender": "M",
    "birthday": "01-01",
    "birthyear": "1990",
    "mobile": "010-1234-5678"
  }
}
// 네이버 응답에서 'response' 속성 내부의 'id' 값을 추출
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return (String) response.get("id");

Naver같은 경우 attributes 속, response라는 곳 안에 정보들이 위치했다. 따라서 값을 추출할 때에도 먼저 response를 정의한 다음, response에서 정보를 추출해야 한다.

여기서 추출하는 값은 id, name, email 총 세 개이다. 모두 String 값으로 변환 후 리턴한다.


KakaoOAuth2UserInfo

public class KakaoOAuth2UserInfo extends OAuth2UserInfo {

    public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        Object id = attributes.get("id");
        return id != null ? id.toString() : null; // Long 타입을 String으로 변환
    }

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

    @Override
    public String getEmail() {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
        return (String) account.get("email"); // 이메일이 존재하면 반환
    }
}

🟡 KakaoResponse 형태

{
  "id": 123456789,
  "connected_at": "2023-01-01T00:00:00Z",
  "properties": {
    "nickname": "홍길동",
    "profile_image": "http://k.kakaocdn.net/dn/ExampleProfileImage.jpg",
    "thumbnail_image": "http://k.kakaocdn.net/dn/ExampleThumbnailImage.jpg"
  },
  "kakao_account": {
    "profile_needs_agreement": false,
    "profile": {
      "nickname": "홍길동",
      "thumbnail_image_url": "http://k.kakaocdn.net/dn/ExampleThumbnailImage.jpg",
      "profile_image_url": "http://k.kakaocdn.net/dn/ExampleProfileImage.jpg",
      "is_default_image": false
    },
    "email_needs_agreement": false,
    "is_email_valid": true,
    "is_email_verified": true,
    "email": "johndoe@example.com"
  }
}

Kakao는 가장 복잡한 형태를 띄고 있따. 먼저 id의 경우 가장 바깥에 위치했지만, nicknamekakao_accountprofile에, emailkakao_account에 위치했다.

따라서 값을 가져올 때도 각 위치에 알맞게 get()을 해주어야 한다. 특히 카카오의 idLong 타입인 만큼, toString()을 통해 String 타입으로 변환해야 한다.


OAuth2UserInfoFactory

OAuth2UserInfoFactory 클래스는 다양한 소셜 로그인 제공자에 대한 사용자 정보를 처리하기 위해 OAuth2UserInfo의 구체적인 구현체를 생성하는 팩토리 클래스이다.

이 클래스는 클라이언트가 소셜 로그인 제공자에 따라 올바른 OAuth2UserInfo 구현체를 받을 수 있도록 한다.

public class OAuth2UserInfoFactory {

    public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
        if ("google".equalsIgnoreCase(registrationId)) {
            return new GoogleOAuth2UserInfo(attributes);
        } else if ("kakao".equalsIgnoreCase(registrationId)) {
            return new KakaoOAuth2UserInfo(attributes);
        } else if ("naver".equalsIgnoreCase(registrationId)) {
            return new NaverOAuth2UserInfo(attributes);
        } else {
            throw new IllegalArgumentException("Invalid registration ID");
        }
    }
}

✅ 역할

  • 구현체 생성:

    • 소셜 로그인 제공자에 따라 적절한 OAuth2UserInfo 구현체를 생성한다.
  • 객체 제공:

    • 사용자 정보를 제공하는 서비스에서 소셜 로그인 제공자를 기반으로 사용자 정보를 추출할 때 필요한 OAuth2UserInfo 객체를 제공한다.
  • 소셜 로그인 제공자별 로직

    • 등록 ID에 따라 다형성을 제공하여 새로운 소셜 로그인 제공자를 지원하기 용이하게 한다.

✅ 메서드

public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes)

  • 설명: 소셜 로그인 제공자(registrationId)와 사용자 정보(attributes)를 기반으로 적절한 OAuth2UserInfo 구현체를 반환하는 정적 메서드

  • 매개변수:

    • registrationId: 소셜 로그인 제공자의 식별자 (예: "google", "kakao", "naver" 등). 이를 통해 어떤 소셜 로그인 제공자로부터 사용자 정보를 받았는지를 판단한다.
    • attributes: 소셜 로그인 제공자로부터 받은 사용자 정보를 담고 있는 Map<String, Object>이다. 이 정보를 사용하여 사용자 ID, 이름, 이메일 등을 추출한다.
  • 동작:

    • 제공자 ID가 "google"이면 GoogleOAuth2UserInfo 객체를 생성하여 반환
    • 제공자 ID가 "kakao"이면 KakaoOAuth2UserInfo 객체를 생성하여 반환
    • 제공자 ID가 "naver"이면 NaverOAuth2UserInfo 객체를 생성하여 반환
    • 지원되지 않는 제공자 ID가 들어오면 IllegalArgumentException 예외를 발생시킴
  • 반환값:

    • 각 소셜 로그인 제공자에 맞는 OAuth2UserInfo 구현체 객체

OAuth2LoginSuccessHandler

OAuth2LoginSuccessHandler 클래스는 사용자가 OAuth2 로그인에 성공했을 때의 작업을 정의하는 로그인 성공 핸들러이다.

SimpleUrlAuthenticationSuccessHandler를 확장하여 로그인 성공 후의 커스터마이즈된 처리를 지원한다. 주로 JWT 토큰 발급, 사용자 정보 응답 반환 등을 처리한다.

@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomOAuth2UserService oAuth2UserService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        // CustomOAuth2User 객체를 생성
        CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        String socialType = customOAuth2User.getSocialType();

        // OAuth2UserInfo를 사용하여 사용자 정보를 가져옴
        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(socialType, customOAuth2User.getAttributes());

        User user = oAuth2UserService.getUserByOAuth2UserInfo(userInfo, socialType);
        CustomUserDetails userDetails = new CustomUserDetails(user);

        // CustomUserDetails를 Authentication으로 변환
        Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(auth);

        String accessToken = jwtTokenProvider.createToken(auth);
        String refreshToken = jwtTokenProvider.createRefreshToken(auth);

        response.setHeader("Authorization", "Bearer " + accessToken);
        response.setHeader("Refresh-Token", refreshToken);

        JsonResponse jsonResponse = new JsonResponse(
                HttpServletResponse.SC_OK,
                "로그인 성공",
                new UserResponseDTO(customOAuth2User.getName(), customOAuth2User.getEmail())
        );

        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        response.getWriter().write(jsonResponse.toJson());
    }
}

✅ 역할

  • JWT 토큰 생성:

    • OAuth2 로그인 성공 후 사용자의 정보를 기반으로 JWT 토큰을 생성하여 클라이언트에 전달한다.
  • CustomUserDetails 생성:

  • 사용자 정보를 기반으로 CustomUserDetails를 생성하여 Spring Security의 컨텍스트에 설정한다.

  • JSON 형식의 응답 반환:

    • 로그인 성공 시 사용자에게 JSON 형식의 응답을 반환한다.

🔄️ 동작 방식

  • OAuth2LoginSuccessHandler 클래스는 OAuth2 로그인 성공 후 사용자 정보를 처리하고 JWT 토큰을 생성하여 클라이언트에 전달한다.

  • 로그인 성공 시 CustomOAuth2User 객체를 기반으로 사용자 정보를 추출하고 데이터베이스에서 사용자 정보를 가져오거나 새로 생성한다.

  • CustomUserDetails를 사용하여 Spring Security의 인증 컨텍스트를 업데이트하며, JwtTokenProvider를 사용하여 액세스 토큰과 리프레시 토큰을 생성한다.

  • 성공적인 로그인 응답을 JSON 형식으로 클라이언트에 반환하며, 응답 헤더에 JWT 토큰을 설정한다.

💻 응답 형식

{
  "statusCode": 200,
  "message": "로그인 성공",
  "data": {
    "username": "홍길동",
    "email": "abcd1234@gmail.com"
  }
}

OAuth2LoginFailureHandler

사용자가 OAuth2 로그인 실패 후 수행할 작업을 정의하는 로그인 실패 핸들러이다. 실패 시, 실패 메시지와 HTTP 상태 코드를 포함한 JSON 응답을 작성하여 클라이언트에 전달한다.

@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        JsonResponse jsonResponse = new JsonResponse(
                HttpServletResponse.SC_UNAUTHORIZED,
                "로그인 실패",
                null
        );

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write(jsonResponse.toString());
    }
}

✅ 역할

  • JSON 응답 반환:
    • OAuth2 로그인 실패 시 클라이언트에게 적절한 JSON 응답을 반환한다.
    • 응답에는 로그인 실패 메시지와 상태 코드가 포함된다.

🔄️ 동작 방식

  • JsonResponse 객체를 생성하여 로그인 실패 메시지와 HTTP 상태 코드를 포함한 JSON 응답을 준비한다.

  • 응답의 상태 코드를 HttpServletResponse.SC_UNAUTHORIZED (401 Unauthorized)로 설정한다.

  • 응답의 콘텐츠 타입을 application/json으로 설정한다.

  • JSON 응답을 클라이언트에 작성한다.

💻 응답 형식

{
  "statusCode": 401,
  "message": "로그인 실패",
  "data": {
    null
  }
}

Test

이제 정말 OAuth 로그인 구현이 끝났다. 지금까지 코드를 모두 작성했다면 문제 없이 잘 작동될 것이다.

Google 로그인 테스트

Google 로그인 링크: http://localhost:9090/oauth2/authorization/google

링크로 들어가 실제 이메일을 입력하고, <다음> 버튼과 <계속> 버튼으로 로그인을 진행한다.

로그인이 끝나면 화면에 JSON 응답이 띄워진 것을 확인할 수 있다. 또한 DB에도 정상적으로 정보가 들어간다.

Naver 로그인 링크: http://localhost:9090/oauth2/authorization/naver

마찬가지로 링크에 들어가서 실제 네이버 아이디와 비밀번호로 로그인을 진행한다. 결과는 동일하다.

Kakao 로그인 테스트

Kakao 로그인 링크: http://localhost:9090/oauth2/authorization/kakao

카카오 또한 링크에 들어가서 로그인을 진행하면 정상적으로 처리된다.

DB의 User 조회

OAuth 로그인 테스트 결과, social_idsocial_type, emailusername 등에 알맞은 값이 들어오는 것을 확인할 수 있다.


마치며 ✈️

이번 게시물을 마지막으로 프로젝트는 끝이 났다. 처음에 계획한 것들을 모두 이루었고, 중간에 필요하다 싶은 기능들도 모두 적절히 잘 추가한 것 같아 뿌듯하다.

중간에 다른 것들도 공부하고 준비하느라 3주 좀 넘게 걸려버렸지만 확실히 처음과 비교했을 때 각 기술 스택에 관한 지식도 늘었고, 무엇보다 기존에 부족했던 동작 방식또한 이해할 수 있게 되어 코드를 보는 눈이 넓어진 것 같다.

앞으로도 계속 발전하는 개발자가 되어 더 다양한 프로젝트들을 제작해 보고 싶다!

profile
백엔드 개발자

0개의 댓글

관련 채용 정보