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

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

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

👤 OAuth 2.0 로그인 구현

1) 기존 User와의 통합

이번 OAuth 로그인 구현에서 가장 신경쓴 부분은 바로 기존 User 정보와의 통합이다. 물론 대형 서비스에서는 각각 엔티티를 구별해 구현하는 경우도 많지만, 나는 OAuth 로그인에도 JWT 토큰 기반 로그아웃을 적용하고 싶었기에 최대한 자체 로그인과 OAuth 로그인이 통합되도록 코드를 작성해 보았다.

🤔 User: 자체 로그인 + OAuth 로그인?

우선 같은 User 엔티티 안에서 가장 효율적으로 두 로그인을 함께 저장할 수 있는 방법에 대해 생각했다.

  • SocialType
  • SocialId

이 두 필드를 만들어 OAuth 로그인 시에만 값이 들어갈 수 있게 하고, 비밀번호 같은 경우는 자체 로그인만 사용하도록 코드를 구현했다.

그 밖에 JWT 토큰이나 가입 시간, 업데이트 시간같은 경우 @TimeStamp 어노테이션을 사용했기에 크게 문제될 것은 없었다. 결과, 엔티티 통합은 성공적으로 끝났다.

또한 같은 방식으로 JWTAccessTokenRefreshToken을 발급했기에 기존 로그아웃 방식을 동일하게 적용할 수 있게 되었다.

2) Json 응답에 맞춰 추출

저번 게시물에서 설명했듯, 이번 프로젝트에서 사용할 외부 플랫폼은 총 3개이다.

  • Google
  • Kakao
  • Naver

아무래도 국내에서 가장 자주 쓰이는 대표 플랫폼들인 만큼 자료가 방대했다. 그러나 여러 개를 동시에 구현할 경우, 각 플랫폼의 응답 형태에 맞게 구현해야 한다.

그래서 본격적인 구현 전에, 미리 Google, Kakao, Naver의 로그인 성공 후 액세스 토큰을 이용해 사용자 정보를 요청할 때 반환되는 JSON 응답 예시를 알아보려고 한다.

🔵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"
}

Google은 보다시피 매우 간단하게 구성되어 있다. 여기서 보통 사이트에서 사용되는 특성은 다음과 같다.

  • sub: 소셜 로그인의 ID
  • name: 해당 사용자의 이름
  • 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"
  }
}

네이버는 이렇게 idname, email 등의 정보가 response라는 곳 안에 담겨져 있다. 따라서 정보를 가져올 때도 response 에서 가져와야 한다는 Google과의 차이점이 존재한다.

  • id: 소셜 로그인의 ID
  • name: 해당 사용자의 이름
  • 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는 가장 복잡한 Json 응답 형태를 가지고 있다. 먼저 id의 경우 Google과 같이 가장 바깥쪽에 위치했지만, emailkakao_account안에, nicknameproperties에 위치해있다.

아무래도 각각 다른 위치에 있기 때문에 구현할 때에도 좀 더 신경써야 한다. 참고로 nicknamekakao_accountprofile 안에도 존재한다.

  • id: 소셜 로그인의 ID
  • nickname: 해당 사용자의 이름
  • email: 사용자의 이메일 주소

이제 이 구조를 기억하고 OAuth 2.0 로그인 구현을 시작해 보자.


🗂️ 패키지 구조

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

✔️ 변경된 클래스

  1. SecurityConfig.java

    • OAuth2 로그인 설정 추가
    • OAuth2 로그인 성공 및 실패 핸들러 추가
  2. CustomOAuth2User.java

    • OAuth2 사용자 정보를 처리하는 클래스 추가
  3. User.java

    • 소셜 타입과 소셜 ID 필드 추가
    • OAuth 사용자 업데이트 메서드 추가
  4. OAuth2LoginFailureHandler.java & OAuth2LoginSuccessHandler.java

    • OAuth2 로그인 성공 및 실패 핸들러 구현
  5. CustomOAuth2UserService.java

    • OAuth2 사용자 정보를 로드하고 데이터베이스에 저장하는 서비스 추가
  6. OAuth2UserInfo 클래스들

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

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

    • 소셜 ID로 사용자를 조회하는 메서드 추가

SecurityConfig

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2LoginSuccessHandler oauth2LoginSuccessHandler;
    private final OAuth2LoginFailureHandler oauth2LoginFailureHandler;
    
    @Bean // SecurityFilterChain 설정
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                ··· 생략 ···
                // OAuth 로그인 설정
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/login")
                        .failureHandler(oauth2LoginFailureHandler)
                        .successHandler(oauth2LoginSuccessHandler)
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(customOAuth2UserService)
                        )
                )
                ··· 생략 ···

        return httpSecurity.build();
    }

OAuth 로그인 설정

  1. .oauth2Login(oauth2 -> oauth2...)

    • oauth2Login 메서드는 Spring Security에서 OAuth2 로그인을 구성하기 위해 사용된다. 이 블록 안에서 OAuth2 로그인과 관련된 다양한 설정을 할 수 있다.
  2. .loginPage("/login")

    • loginPage 메서드는 사용자가 인증되지 않았을 때 리디렉션할 로그인 페이지를 지정한다. 이 경우, /login URL로 리디렉션된다.
  3. .failureHandler(oauth2LoginFailureHandler)

    • failureHandler 메서드는 OAuth2 인증이 실패했을 때 실행될 핸들러를 지정한다. oauth2LoginFailureHandler는 인증 실패 시 수행할 동작을 정의한 클래스이다. 일반적으로 실패 이유를 로깅하거나 사용자에게 오류 메시지를 보여주는 역할을 한다.
  4. .successHandler(oauth2LoginSuccessHandler)

    • successHandler 메서드는 OAuth2 인증이 성공했을 때 실행될 핸들러를 지정한다. oauth2LoginSuccessHandler는 인증 성공 시 수행할 동작을 정의한 클래스이다. 일반적으로 사용자 정보를 로드하고 세션을 설정하거나 JWT 토큰을 발급하는 역할을 한다.
  5. .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))

    • userInfoEndpoint 메서드는 OAuth2 인증 과정에서 사용자 정보를 가져올 엔드포인트를 설정한다.
    • userService 메서드는 사용자 정보를 로드하기 위한 서비스를 지정한다. 여기서 customOAuth2UserService는 OAuth2 공급자로부터 사용자 정보를 가져와 애플리케이션의 사용자 정보로 변환하는 역할을 한다.

이 설정은 Spring Security에서 OAuth2 로그인을 처리하기 위해 필요한 설정을 구성한다. 사용자가 로그인 페이지를 방문하면, 지정된 OAuth2 공급자(Google, Kakao, Naver 등)를 통해 인증을 시도한다.

인증 과정에서 발생할 수 있는 성공 및 실패 상황에 대해 각각의 핸들러를 정의해 적절한 처리를 수행하고, customOAuth2UserService를 사용하여 OAuth2 공급자로부터 받은 정보를 애플리케이션의 사용자 정보로 변환한다.


User

    private String socialType; // 소셜 타입 (자체 로그인의 경우 Null)
    private String socialId; // 소셜 ID  (자체 로그인의 경우 Null)
    
    public void updateOAuthUser(String username, String email, String socialType, String socialId) {
        this.username = username;
        if (email != null) {
            this.email = email;
        }
        this.socialType = socialType;
        this.socialId = socialId;
        this.enabled = true; // OAuth 로그인 후 사용자는 활성화 상태
    }

✅ 변경사항

  • socialType, socialId 컬럼 추가 (자체 로그인의 경우 null로 설정)

    • socialType: google, kakao, naverprovider 값을 설정한다.
    • socialId: OAuth2 플랫폼에서 주어지는 고유한 ID 값을 설정한다.
  • updateOAuthUser 메서드

    • OAuth 유저의 정보를 업데이트하는 메서드
    • username, email, socialType, socialId를 받아와 업데이트한다.
    • OAuth 로그인 후 사용자는 활성화 상태이므로 enabledtrue로 설정한다.

CustomOAuth2User

이 클래스는 OAuth2 사용자 정보를 처리하며, Google, Kakao, Naver와 같은 소셜 로그인 사용자 정보를 통합하여 관리한다.

@Getter
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2User oAuth2User;
    private final String socialType;
    private final String id;
    private final String name;
    private final String email;

    public CustomOAuth2User(OAuth2User oAuth2User, String socialType) {
        this.oAuth2User = oAuth2User;
        this.socialType = socialType;
        this.id = extractId(oAuth2User, socialType);
        this.name = extractName(oAuth2User, socialType);
        this.email = extractEmail(oAuth2User, socialType);
    }

    private String extractId(OAuth2User oAuth2User, String socialType) {
        switch (socialType) {
            case "google":
                return (String) oAuth2User.getAttribute("sub");
            case "kakao":
                Object id = oAuth2User.getAttribute("id");
                return id != null ? id.toString() : null; // Long 타입을 String으로 변환
            case "naver":
                Map<String, Object> response = (Map<String, Object>) oAuth2User.getAttribute("response");
                return (String) response.get("id");
            default:
                throw new IllegalArgumentException("Unsupported social type: " + socialType);
        }
    }


    private String extractName(OAuth2User oAuth2User, String socialType) {
        switch (socialType) {
            case "google":
                return (String) oAuth2User.getAttribute("name");
            case "kakao":
                Map<String, Object> account = (Map<String, Object>) oAuth2User.getAttribute("kakao_account");
                Map<String, Object> profile = (Map<String, Object>) account.get("profile");
                return (String) profile.get("nickname");
            case "naver":
                Map<String, Object> response = (Map<String, Object>) oAuth2User.getAttribute("response");
                return (String) response.get("name");
            default:
                throw new IllegalArgumentException("Unsupported social type: " + socialType);
        }
    }

    private String extractEmail(OAuth2User oAuth2User, String socialType) {
        switch (socialType) {
            case "google":
                return (String) oAuth2User.getAttribute("email");
            case "kakao":
                Map<String, Object> account = (Map<String, Object>) oAuth2User.getAttribute("kakao_account");
                return (String) account.get("email");
            case "naver":
                Map<String, Object> response = (Map<String, Object>) oAuth2User.getAttribute("response");
                return (String) response.get("email");
            default:
                throw new IllegalArgumentException("Unsupported social type: " + socialType);
        }
    }

	··· 생략 ···
}

✅ 구성 요소

  • oAuth2User: OAuth2 사용자 정보를 담고 있는 객체
  • socialType: 소셜 로그인 타입 (Google, Kakao, Naver)
  • id: 소셜 플랫폼에서 제공하는 사용자 ID
  • name: 소셜 플랫폼에서 제공하는 사용자 이름
  • email: 소셜 플랫폼에서 제공하는 사용자 이메일

✅ 생성자

public CustomOAuth2User(OAuth2User oAuth2User, String socialType) {
    this.oAuth2User = oAuth2User;
    this.socialType = socialType;
    this.id = extractId(oAuth2User, socialType);
    this.name = extractName(oAuth2User, socialType);
    this.email = extractEmail(oAuth2User, socialType);
}
  • 매개변수:
    • oAuth2User: OAuth2 사용자 정보 객체
    • socialType: 소셜 로그인 타입 (google, kakao, naver)
  • 동작: 소셜 로그인 타입에 따라 ID, 이름, 이메일 정보를 추출하여 초기화한다.

✅ 메서드

1) extractId(OAuth2User oAuth2User, String socialType)

  • 기능: 소셜 로그인 타입에 따라 사용자 ID를 추출한다.
  • 매개변수:
    • oAuth2User: OAuth2 사용자 정보 객체
    • socialType: 소셜 로그인 타입
  • 동작:
    • Google: sub 속성에서 ID 추출
    • Kakao: id 속성에서 ID 추출
    • Naver: response 객체에서 id 속성 추출
  • 반환값: 사용자 ID (String)

2) extractName(OAuth2User oAuth2User, String socialType)

  • 기능: 소셜 로그인 타입에 따라 사용자 이름을 추출한다.
  • 매개변수:
    • oAuth2User: OAuth2 사용자 정보 객체
    • socialType: 소셜 로그인 타입
  • 동작:
    • Google: name 속성에서 이름 추출
    • Kakao: kakao_account 객체의 profile 객체에서 nickname 속성 추출
    • Naver: response 객체에서 name 속성 추출
  • 반환값: 사용자 이름 (String)

3) extractEmail(OAuth2User oAuth2User, String socialType)

  • 기능: 소셜 로그인 타입에 따라 사용자 이메일을 추출한다.
  • 매개변수:
    • oAuth2User: OAuth2 사용자 정보 객체
    • socialType: 소셜 로그인 타입
  • 동작:
    • Google: email 속성에서 이메일 추출
    • Kakao: kakao_account 객체에서 email 속성 추출
    • Naver: response 객체에서 email 속성 추출
  • 반환값: 사용자 이메일 (String)

UserRepository

Optional<User> findBySocialId(String socialId);

UserRepository에서 소셜 ID로 사용자를 조회하는 메서드를 추가한다. 이 메서드는 향후 CustomOAuth2UserService에서 소셜 로그인 사용자 정보를 검색하고 가져올 때 쓰인다.


CustomOAuth2UserService

이 클래스는 OAuth2 사용자 정보를 로드하고, 사용자 정보를 저장하거나 업데이트하는 역할을 담당한다. 또한 인증이 원활하게 작동되도록 지원하며, 기존 사용자 정보와의 통합을 담당한다.

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

    private final UserRepository userRepository;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2User oauth2User = getOAuth2User(userRequest);

        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oauth2User.getAttributes());

        saveOrUpdateUser(userInfo, registrationId);


        return new CustomOAuth2User(oauth2User, registrationId);
    }

    private OAuth2User getOAuth2User(OAuth2UserRequest userRequest) {
        // 기본 OAuth2UserService를 사용하여 사용자 정보를 가져온다.
        return new DefaultOAuth2UserService().loadUser(userRequest);
    }


    private User saveOrUpdateUser(OAuth2UserInfo userInfo, String registrationId) {
        User user = userRepository.findBySocialId(userInfo.getId())
                .orElseGet(() -> createNewUser(userInfo, registrationId));

        // 업데이트할 필요가 있는 경우 필드 업데이트
        user.updateOAuthUser(userInfo.getName(), userInfo.getEmail(), registrationId, userInfo.getId());

        return userRepository.save(user);
    }

    private User createNewUser(OAuth2UserInfo userInfo, String registrationId) {
        return User.builder()
                .username(userInfo.getName())
                .email(userInfo.getEmail()) // 이메일 추가
                .socialType(registrationId)
                .socialId(userInfo.getId())
                .password("") // OAuth 로그인에서는 비밀번호가 필요 없으므로 빈 문자열로 설정
                .enabled(true)
                .build();
    }

    public User getUserByOAuth2UserInfo(OAuth2UserInfo userInfo, String registrationId) {
        return userRepository.findBySocialId(userInfo.getId())
                .orElseGet(() -> createNewUser(userInfo, registrationId));
    }
}

✅ 구성 요소

  • userRepository: 사용자 정보를 저장하고 조회하는 리포지토리

✅ 메서드

1) loadUser(OAuth2UserRequest userRequest)

  • 기능: OAuth2 로그인 요청을 처리하여 사용자 정보를 로드한다.
  • 매개변수:
    • userRequest: OAuth2 로그인 요청 정보
  • 동작:
    • registrationId를 통해 로그인 제공자를 식별한다.
    • getOAuth2User 메서드를 호출하여 기본 OAuth2 사용자 정보를 로드한다.
    • OAuth2UserInfoFactory를 사용하여 로그인 제공자에 따라 적절한 OAuth2UserInfo 객체를 생성한다.
    • saveOrUpdateUser 메서드를 호출하여 사용자 정보를 저장하거나 업데이트한다.
    • CustomOAuth2User 객체를 생성하여 반환한다.
  • 반환값: CustomOAuth2User 객체

2) getOAuth2User(OAuth2UserRequest userRequest)

  • 기능: 기본 OAuth2 사용자 정보를 로드한다.
  • 매개변수:
    • userRequest: OAuth2 로그인 요청 정보
  • 동작:
    • DefaultOAuth2UserService를 사용하여 사용자 정보를 가져온다.
  • 반환값: OAuth2User 객체

3) saveOrUpdateUser(OAuth2UserInfo userInfo, String registrationId)

  • 기능: 사용자 정보를 저장하거나 업데이트한다.
  • 매개변수:
    • userInfo: OAuth2 사용자 정보 객체
    • registrationId: 로그인 제공자 ID
  • 동작:
    • userRepository를 사용하여 소셜 ID로 사용자를 조회한다.
    • 사용자가 존재하지 않으면 createNewUser 메서드를 호출하여 새 사용자를 생성한다.
    • 사용자 정보를 업데이트하고 저장한다.
  • 반환값: 저장된 User 객체

4) createNewUser(OAuth2UserInfo userInfo, String registrationId)

  • 기능: 새 사용자를 생성한다.
  • 매개변수:
    • userInfo: OAuth2 사용자 정보 객체
    • registrationId: 로그인 제공자 ID
  • 동작:
    • User 객체를 생성하고, 필수 정보를 설정한다.
    • OAuth 로그인에서는 비밀번호가 필요 없으므로 빈 문자열로 설정한다.
  • 반환값: 새로 생성된 User 객체

5) getUserByOAuth2UserInfo(OAuth2UserInfo userInfo, String registrationId)

  • 기능: OAuth2 사용자 정보로 사용자를 조회하거나 새로 생성한다.
  • 매개변수:
    • userInfo: OAuth2 사용자 정보 객체
    • registrationId: 로그인 제공자 ID
  • 동작:
    • userRepository를 사용하여 소셜 ID로 사용자를 조회한다
    • 사용자가 존재하지 않으면 createNewUser 메서드를 호출하여 새 사용자를 생성한다.
  • 반환값: 조회된 또는 새로 생성된 User 객체

Continue ✈️

이제 OAuth 패키지의 핸들러, UserInfo 관련 클래스들을 제외한 설정은 모두 끝났다. 다음 게시물에서는 이어서 OAuth 패키지 안에 있는 클래스들을 소개하고, 테스트 방법과 결과를 설명하도록 하겠다.

profile
백엔드 개발자

0개의 댓글

관련 채용 정보