소셜 로그인 서비스 레이어 구현

이전 글에서 컨트롤러 호출 방법과 컨트롤러까지 구현했다.

컨트롤러에서 Provider 이름과 인증 코드를 받아오기까지는 했으니 이제 아래 그림에서 토큰 발급 부터 진행한다.


1. 필요한 DTO 구현

1-1. 토큰 발급 과정에 필요한 DTO 구현

OauthTokenResponse

프론트에서 받은 인증 코드와 Provider 이름으로 Provider에 토큰 발급을 요청하고, 발급 받은 토큰으로 유저 정보를 요청한다.

토큰 발급 시 토큰 정보를 받을 DTO가 아래 OAuthTokenResponse 이다.

@Getter
public class OAuthTokenResponse {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @Builder
    public OAuthTokenResponse(String accessToken, String tokenType) {
        this.accessToken = accessToken;
        this.tokenType = tokenType;
    }
}

1-2. 유저 정보 요청에 필요한 DTO 구현

OAuthAttributes

위에서 발급 받은 토큰으로 Provider에 요청하여 제공 받은 유저 정보를 담는 DTO가 OAuthAttributes 이다.

Provider가 제공하는 유저 정보에 temp_nickname 이라는 값을 추가해서 사용하였다.

Provider마다 제공하는 정보 형식이 달라서 팩토리 메서드로 구현했다.

만약 깃허브나 페이스북 로그인이 추가 된다면 OAuthAttributes 클래스와 서비스 레이어의 검증 로직 정도만 수정하면 될것이다.

@Getter
public class OAuthAttributes {

    private String nickname;
    private String email;
    private String profileImage;
    private String provider;

    @Builder
    public OAuthAttributes(String nickname, String email, String profileImage, String provider) {
        this.nickname = nickname;
        this.email = email;
        this.profileImage = profileImage;
        this.provider = provider;
    }

    public static OAuthAttributes of(String providerName, Map<String, Object> attributes) throws IllegalArgumentException {
        switch (providerName) {
            case "카카오":
                return ofKakao(providerName, attributes);
            case "구글":
                return ofGoogle(providerName, attributes);
            case "네이버":
                return ofNaver(providerName, attributes);
            default:
                throw new IllegalArgumentException("허용되지 않은 접근입니다.");
        }
    }

    public User toUser() {
        return User.builder()
            .nickname(nickname)
            .email(email)
            .password("oauth2")
            .profileImage(profileImage)
            .type(LoginType.fromString(provider))
            .build();
    }

    private static OAuthAttributes ofKakao(String providerName, Map<String, Object> attributes) {
        Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) account.get("profile");
        return OAuthAttributes.builder()
            .nickname((String) attributes.get("temp_nickname"))
            .email(account.get("email") + "/" + providerName)
            .profileImage((String) profile.get("profile_image_url"))
            .provider(providerName)
            .build();
    }

    private static OAuthAttributes ofGoogle(String providerName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
            .nickname((String) attributes.get("temp_nickname"))
            .email(attributes.get("email") + "/" + providerName)
            .profileImage((String) attributes.get("picture"))
            .provider(providerName)
            .build();
    }

    private static OAuthAttributes ofNaver(String providerName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return OAuthAttributes.builder()
            .nickname((String) attributes.get("temp_nickname"))
            .email(response.get("email") + "/" + providerName)
            .profileImage((String) response.get("profile_image"))
            .provider(providerName)
            .build();
    }
}

2. 서비스 레이어 구현

과정은 아래와 같다.

  1. 우선 우리 서비스에서 제공하는 소셜 로그인 종류를 검증한다. (네이버, 카카오, 구글)
  2. oauth2-client에서 제공하는 ClientRegistrationRepository에서 ClientRegistration 객체를 조회한다.
    ClientRegistration 객체는 기본 설정 + yml 파일에서 설정한 값을 토대로 oauth2 과정에 필요한 정보를 제공한다.
  3. 인증 코드와 ClientRegistration 객체로 Provider에 액세스 토큰을 요청한다.
  4. 발급 받은 액세스 토큰으로 Provider에 유저 정보를 요청한다.
  5. 제공 받은 유저 정보로 회원가입 / 로그인을 진행한다.

AuthService

AuthService의 전문은 아래와 같다.

4번 까지는 소셜 로그인 공통 로직이다.

5번부터는 각자 서비스에 맞는 형태로 구현하면 된다.

본인은 4번에서 제공받은 유저 정보인 OAuthAttributes 에 임시 닉네임 값을 추가하여 사용하였다.

이후 소셜 로그인 시 변경이 있으면 수정하고, 가입되지 않은 유저면 회원가입을 진행하는 로직을 작성했다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

    private static final String TEMP_NICKNAME_PREFIX = "임시닉네임";
    private final ClientRegistrationRepository clientRegistrationRepository;
    private final UserRepository userRepository;

    @Transactional
    public User oauth2Login(String code, String provider) {
        validateProvider(provider);
        ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(provider);
        OAuthTokenResponse tokenResponse = requestAccessToken(code, registration);
        User uerProfile = getUerProfile(tokenResponse.getAccessToken(), registration);
        return updateOrSave(uerProfile);
    }

    private void validateProvider(String provider) {
        List<String> validProvider = List.of("kakao", "naver", "google");
        if (!validProvider.contains(provider)) {
            throw new IllegalArgumentException("허용되지 않은 접근입니다.");
        }
    }

    private OAuthTokenResponse requestAccessToken(String code, ClientRegistration registration) {
        return WebClient.create()
            .post()
            .uri(registration.getProviderDetails().getTokenUri())
            .headers(header -> {
                header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
                header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
            })
            .bodyValue(createTokenRequest(code, registration))
            .retrieve()
            .bodyToMono(OAuthTokenResponse.class)
            .block();
    }

    private MultiValueMap<String, String> createTokenRequest(String code, ClientRegistration registration) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("grant_type", "authorization_code");
        formData.add("redirect_uri", registration.getRedirectUri());
        formData.add("client_secret", registration.getClientSecret());
        formData.add("client_id", registration.getClientId());
        return formData;
    }

    private User getUerProfile(String accessToken, ClientRegistration registration) {
        Map<String, Object> userAttributes = requestUserAttributes(registration, accessToken);
        userAttributes.put("temp_nickname", createNonDuplicateNickname());
        return OAuthAttributes.of(registration.getClientName(), userAttributes).toUser();
    }

    private Map<String, Object> requestUserAttributes(ClientRegistration registration, String accessToken) {
        return WebClient.create()
            .get()
            .uri(registration.getProviderDetails().getUserInfoEndpoint().getUri())
            .headers(header -> header.setBearerAuth(accessToken))
            .retrieve()
            .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
            .block();
    }

    private String createNonDuplicateNickname() {
        String tempNickname = createRandomNickname();
        while (isDuplicateNickname(tempNickname)) {
            tempNickname = createRandomNickname();
        }
        return tempNickname;
    }

    private String createRandomNickname() {
        Random random = new Random();
        int randomNumber = random.nextInt(999999) + 1;
        return TEMP_NICKNAME_PREFIX + String.format("%06d", randomNumber);
    }

    private boolean isDuplicateNickname(String nickname) {
        return userRepository.findByNickname(nickname).isPresent();
    }

    private User updateOrSave(User uerProfile) {
        User oauth2User = userRepository.findByEmail(uerProfile.getEmail())
            .map(user -> user.updateOauth2Profile(uerProfile))
            .orElse(uerProfile);
        return userRepository.save(oauth2User);
    }
}

3. 결과 확인

이전 글에서 네이버 로그인 링크가 아래와 같았다.

 

https://nid.naver.com/oauth2.0/authorize?client_id=9NjBfBE6YM2diWzMy19n&redirect_uri=http://localhost:8080/api/auth/oauth2/naver&response_type=code

 

또한 컨트롤러 구현 쪽에서 소셜 로그인 성공 시 프론트 URL로 리다이렉트 시키도록 해두었다.

앱 등록을 안해서 등록한 멤버만 위 링크를 사용 가능하겠지만 링크를 눌러 소셜 로그인을 하면

백엔드 서버에 인증 코드가 가고, 백엔드 서버에서 Oauth2 과정을 거쳐 회원 정보를 얻어온 다음 프론트 페이지로 리다이렉트 한다.

우선은 프론트도 로컬에서 작업할거라 localhost:3000으로 리다이렉트 시켰다.

결과는 아래와 같이 소셜로그인 이후 쿠키로 JWT를 발급함을 알 수 있다.

소셜 로그인 구현

이전 글에서 소셜 로그인 구현을 위한 사전 준비를 하였다.

이제 스프링 부트에서 oauth2-client와 webflux로 소셜 로그인을 구현해볼 것이다.

소셜 로그인 화면은 Provider에서 제공하는거라 딱히 프론트쪽 구현 없이도 테스트 할 수 있다.

 

아래 그림은 카카오 디벨로퍼스에서 제공하는 카카오 로그인 흐름도이다.

구현 방법에 따라 다르겠지만 우선 본인이 구현한 방법에서는 백엔드에서 생각할 건 아래 두가지이다.

  • 리다이렉트 URL로 컨트롤러 호출 시 Provider 이름, 인증 코드 확인
  • 서비스 레이어에서 토큰 발급 후 이 토큰으로 유저 정보 받아오기 

이번 글에서는 컨트롤러를 호출하고 Provider 이름과 인증 코드를 확인하는 부분까지 진행한다.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • webflux, oauth2-client

 

1. 프로젝트 사전 작업

1-1. 의존성 추가

아래 두 라이브러리의 의존성을 추가해준다.

webflux는 서버에서 HTTP 요청을 하기 위해서 사용하고, oauth2-client는 소셜 로그인을 구현하기 위해 사용한다.

// build.gradle.kts
// 소셜 로그인
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation ("org.springframework.boot:spring-boot-starter-webflux")

1-2. application.yml 설정

oauth2-client를 사용하기 위해서는 아래처럼 yml 파일을 설정해줘야한다.

아래에서 # 으로 표시한 클라이언트 ID, 시크릿 키, 리다이렉트 URL 외에는 그대로 써도 무방하다.

변경한다면 client-name는 원하는대로 바꿔도 될듯하고, scope도 각자 설정한 부분만큼 설정하면 될 것 같다.

참고로 구글은 provider depth에 없는 이유는 기본적으로 설정이 되어 있어서다.

클라이언트 ID, 시크릿 키를 확인하는 위치는 이전 글을 참고하자.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: # 발급 받은 Client ID
            client-secret: # 발급 받은 시크릿 키
            redirect-uri: http://localhost:8080/api/auth/oauth2/google # 설정한 리다이렉트 URL
            scope:
              - email
              - profile
            client-name: 구글

          naver:
            client-id: # 발급 받은 Client ID
            client-secret: # 발급 받은 시크릿 키
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: # 설정한 리다이렉트 URL
            scope:
              - name
              - email
              - profile_image
            client-name: 네이버

          kakao:
            client-id: # 발급 받은 Client ID
            client-secret: # 발급 받은 시크릿 키
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            scope:
              - profile_nickname
              - profile_image
              - account_email
            redirect-uri: # 설정한 리다이렉트 URL
            client-name: 카카오

        provider:
          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-info-authentication-method: header
            user-name-attribute: response # Naver 응답 값 resultCode, message, response 중 response 지정

          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-info-authentication-method: header
            user-name-attribute: id # Kakao 응답 값 id, connected_at, properties, kakao_account 중 id 지정

2. 컨트롤러 호출 부분

컨트롤러를 호출하는 부분에 대해 설명해보려 한다.

Provider가 기본적으로 제공하는 화면이 있기 때문에 사용자를 그 화면을 호출하는 링크로 이동시키면 된다.

기본적인 형태는 아래와 같다.

{Provider가 제공하는 로그인 화면 주소}
?client_id={클라이언트 ID}
&redirect_uri={리다이렉트 URL}
&response_type=code

 

아래는 본인이 로컬에서 테스트할 때 사용하는 소셜 로그인 페이지 이동 링크이다.

2-1. 네이버 로그인 주소

기본적인 형태를 따른다.

https://nid.naver.com/oauth2.0/authorize?client_id=9NjBfBE6YM2diWzMy19n&redirect_uri=http://localhost:8080/api/auth/oauth2/naver&response_type=code

2-2. 카카오 로그인 주소

기본적인 형태를 따른다.

https://kauth.kakao.com/oauth/authorize?client_id=b88ca4129b6dcc848a022469f3d6ae1c&redirect_uri=http://localhost:8080/api/auth/oauth2/kakao&response_type=code

2-1. 구글 로그인 주소

구글 로그인 시 scope 값에 따라 제공하는 정보가 다르므로 스코프를 추가하고 나머지는 기본적인 형태를 따른다.

https://accounts.google.com/o/oauth2/v2/auth?client_id=241400103028-mj55qqtl4gv96o35fdavqm6fngb68ske.apps.googleusercontent.com&redirect_uri=http://localhost:8080/api/auth/oauth2/google&response_type=code&scope=email profile


3. 컨트롤러 구현

리다이렉트 URL로 컨트롤러를 호출하므로 GET 요청을 받는다.

또한 하나의 컨트롤러로 여러개의 Provider를 받기 위해 아래처럼 PathVariable로 다형적으로 받도록 설정했다.

또한 인증 코드는 Provider에서 리다이렉트 시 code라는 키 값의 쿼리 스트링으로 주므로 아래처럼 받았다.

소셜 로그인을 진행하고 성공 시 JWT를 쿠키에 담아 반환한다.

그리고 요청을 받는게 백엔드쪽 주소이므로 프론트 URL로 리다이렉트 시켜줬다.

AuthController

@Operation(summary = "소셜 로그인", description = "소셜 로그인 성공 시 쿠키를 반환합니다.")
@ApiResponse(responseCode = "200", description = "로그인에 성공",
    headers = {
        @Header(name = "Set-Cookie", description = "인증 쿠키")
    })
@ApiResponse(responseCode = "400", description = "잘못된 아이디 혹은 비밀번호")
@GetMapping("/oauth2/{provider}")
public ResponseEntity<ApiMessageResponse> oauth2Login(@PathVariable String provider, @RequestParam String code) {
    String jwtCookie = authFacade.oauth2Login(code, provider);
    return ResponseEntity
        .status(HttpStatus.FOUND)
        .header(HttpHeaders.SET_COOKIE, jwtCookie)
        .header(HttpHeaders.LOCATION, appConfig.getFrontDeployUrl())
        .body(new ApiMessageResponse("소셜 로그인에 성공했습니다."));
}

 

소셜 로그인

팀 프로젝트를 진행하면서 일반 회원가입과 소셜 로그인 두 가지 방식을 병행하기로 했다.

사실 이번 프로젝트의 일반 회원가입은 이메일 인증도 해야해서 번거로운 감이 있어서 소셜 로그인이 편할것 같기는 하다.

소셜 로그인이 구현되면 수작업으로 테스트하기도 편할것 같아서 마음에 든다.

 

이번 프로젝트에서는 일단 oauth2-client 를 이용해서 구현했다.

스프링 시큐리티에서도 oauth2 로그인을 지원해주니까 이걸 이용할 수 있을거 같긴 한데 그건 다음에 시도 해봐야겠다.

구현 방법에 따라 흐름은 다를 수 있겠지만 본인이 구현한 대략적인 소셜 로그인 흐름은 아래와 같다.

리다이렉트 URL도 구현에 따라 다르게 설정해도 무방하다.

그림이랑 설명의 번호는 안맞으니까 적당히 보자.

  1. 프론트에서 네이버, 카카오, 구글에서 제공하는 소셜 로그인 페이지로 이동시킨다.
    소셜 로그인 페이지로 이동 시킬 때 URL에 client Id 리다이렉트 URL(Callback URL) 등이 포함된다.
    여기서 리다이렉트 URL은 아래와 같은 형태이다.
    {백엔드 도메인}/백엔드 소셜로그인 API 엔드포인트/{Provider 이름}
    ex) https://midcon.store/api/auth/oauth2/kakao
  2. 사용자가 정보 입력, 개인정보 제공 동의 등의 소셜 로그인 과정을 진행 후 제출한다.
  3. 1번에서 URL에 포함된 리다이렉트 URL을 백엔드 주소로 등록해서 리다이렉트 시킨다.
    리다이렉트 됐을 때 쿼리스트링 형태로 소셜 로그인 Provider에서 인증 코드를 전달 받는다.
    ex) https://localhost:8080/api/auth/oauth2/kakao?code={인증 코드}
  4. 백엔드에서 리다이렉트 된 주소로 GET 요청을 받는다.
  5. 3의 예시처럼 PathVariable과 QueryParam 형태로 Provider 이름과 인증 코드를 받아 Oauth2 인증 과정을 진행한다.
  6. Oauth2 과정으로 Provider에서 받은 유저 정보로 서비스 정책에 맞게 회원가입 시키거나 accessToken을 발급한다.

본인이 사용한 플로우는 대략 위와 같다.

최종 결과물은 아래와 같이 회원가입 혹은 로그인 시킨 후 쿠키JWT를 발급하고 프론트 페이지로 리다이렉트 한다.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • webflux, oauth2-client

1. 사전 작업 - 네이버

소셜 로그인을 하려면 각 소셜 로그인 provider에 해당하는 앱 등록을 해둬야한다.

네이버 디벨로퍼스에서 앱 등록을 한다.

앱 등록하는건 간단하니까 각자 해보면 되고 필요한 부분만 체크했다.

서비스 URL은 본인이 사용할 도메인으로 설정한다.

로컬에서 할거면 localhost로 해주면 된다.

1-1. 클라이언트 ID, 시크릿 키

1-2. Callback URL(Redirect URL)

본인은 로컬에서 테스트할 때와 배포 URL 둘 다 해뒀다.

만약 로컬에서만 할거라면 빨간 색 부분만 추가해도 된다.

본인의 소셜 로그인 엔드포인트가 /api/auth/oauth2/{providerName} 이라서 아래처럼 했다.

엔드포인트를 다른거로 할거라면 마음대로 설정하면 된다.

 

참고로 백엔드에서 설정 할 리다이렉트 URL과 프론트에서 리다이렉트 시켜줄 리다이렉트 URL이 안맞으면 에러가 발생한다.

1-3. 멤버 등록

본인만 테스트할거라면 상관 없지만, 팀 프로젝트라서 팀원들도 소셜 로그인 할 수 있게 하려면 멤버 등록을 해줘야한다.

만약 앱 등록을 해서 통과 된다면 멤버 등록 없이도 할 수 있다.

본인은 아직 하지 않았지만 앱 등록하는게 그렇게 어렵지 않다고 하니 해보는것도 좋을것 같다.


2. 사전 작업 - 카카오

카카오 디벨로퍼스에 들어가서 앱 등록을 한다.

2-1. 클라이언트 ID

카카오는 클라이언트 ID와 시크릿 키가 따로 있다.

 

시크릿 키는 보안 탭에서 확인할 수 있다.

2-2. Callback URL(Redirect URL)

리다이렉트 URL은 네이버와 마지막 PathVariable만 다르게 설정해준다.

마찬가지로 로컬에서 할거면 빨간 영역만 해도 무방하다.

2-3. 멤버 등록

마찬가지로 앱 등록을 안하면 따로 멤버 등록을 해야한다.


3. 사전 작업 - 구글

구글 클라우드 콘솔에서 앱 등록을 한다.

3-1. 클라이언트 ID, 시크릿 키, Callback URL(Redirect URL)

API 및 서비스 탭으로 들어가서 사용자 인증 정보를 확인한다.

리다이렉트 URL은 네이버, 카카오와 마지막 PathVariable만 다르게 설정해준다.

마찬가지로 로컬에서 할거면 빨간 영역만 해도 무방하다.

3-2. 멤버 등록

마찬가지로 앱 인증 전에는 멤버를 수동으로 등록해줘야한다.

+ Recent posts