Spring Boot | OAuth 2 소셜 로그인 구현 (Naver)

yeonk·2022년 11월 10일
1

spring & spring boot

목록 보기
4/10
post-thumbnail

목적 및 개요


진행하는 사이드 프로젝트에서 네이버 소셜 로그인을 구현하게 되었다. 다른 백엔드 개발자분은 카카오를 구현하시기로 하고 각자 진행했는데 나는 OAuth2 client 로 삽질하면서 구현을 못하고 있었다.

그러던 중에 다른 팀원분이 카카오 소셜 로그인 구현하신 것을 보고 크게 반성하며 네이버 소셜 로그인을 구현하였다. OAuth2 client 로 구현하는 것에 집착한 나머지 다른 방법을 사용할 생각을 못했던 것 같다.

다른 팀원 분은 직접 구현하셨고, 그 코드를 기반으로 네이버 로그인도 구현할 수 있었다.
그래서 그 내용에 대해 정리해보려고 한다.






  • 개발 환경

    • Spring Boot 2.7.4

    • Java 11

    • IntelliJ






API 키 발급


소셜 로그인을 위해서는 네이버에서 API 키를 발급 받아야한다.
Naver Developers에서 발급 가능하다.

  • 위 링크를 통해 Naver Developers에 접속한다.

  • Application 탭 - 애플리케이션 등록 탭을 누른다.






  • 애플리케이션 이름: 사용하고자 하는 애플리케이션 이름을 작성한다 (프로젝트명, 브랜드 명 등).

  • 사용 API: 네이버 로그인을 선택한다.






  • 사용자에게 필수적으로 수집할 정보(필수)와 선택적으로 수집할 정보(추가)를 선택한다.

  • 이후에 수정 가능






  • 로그인 오픈 API 서비스 환경: 만들고자 하는 서비스의 환경을 선택한다. 나는 웹 애플리케이션을 제작하므로 PC 웹을 선택했다.

  • 이후에 수정 가능






  • 서비스 URL: http://localhost:8080, https://www.naver.com 과 같이 서비스 URL을 기재한다.

  • 네이버 로그인 Callback URL: authorization code, 토큰, 사용자 정보를 받을 URL을 추가한다.

  • 이후에 수정 가능

  • 이외에도 로고, 서비스 약관 정보 등을 추가할 수 있다.






  • 애플리케이션 목록에서 생성된 애플리케이션을 확인할 수 있다.
  • 생성된 애플리케이션의 Client ID를 누르면 애플리케이션 정보창으로 이동할 수 있다.






  • client ID와 Client Secret을 확인할 수 있고 이를 서비스에 이용하면 된다.






코드 구현


코드를 구현하기 전에 네이버 로그인 개발가이드를 참고하는 것이 좋다.
네이버에서 소셜 로그인을 구현하기 위한 가이드를 제시한 것이므로 가장 먼저 보는 것을 추천한다.
또한 공식 문서를 봐야 제공되는 URI나 파라미터를 알 수 있기 때문에 반드시 한 번은 봐야한다.
참고한 자료들은 하단에 모두 링크해두었으니 필요에 따라 참고하면 될 것 같다.

application.yml

...

social:
  naver:
    params:
      clientId: ${naver-client-id}						// 1
      clientSecret: ${naver-client-secret}				// 2	
    path:
      redirectUri: callbackUrl 							// 3
      userInfoUrl: https://openapi.naver.com/v1/nid/me
      tokenUrl: https://nid.naver.com/oauth2.0/token

...
  • clientId: 발급받은 clientId 기재 (나는 환경변수로 설정함)

  • clientSecret: 발급받은 clientSecret 기재 (나는 환경변수로 설정함)

  • redirectUri: api 발급받을 때 추가한 callbackUrl 기재






DTO

OauthTokenDto

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

@NoArgsConstructor
@AllArgsConstructor
@ToString
@Setter
public class OauthTokenDto {

    @JsonProperty("access_token")
    @Getter
    private String accessToken;

    @JsonProperty("token_type")
    @Getter
    private String tokenType;

    @JsonProperty("refresh_token")
    @Getter
    private String refreshToken;

}



UserInfoOauthDto

import lombok.*;
import sportsmatchingservice.auth.domain.User;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class UserInfoOauthDto {

    private String email;
    private String nickname;
    private String phoneNumber;

    private UserInfoOauthDto(String email, String nickname){
        this.email = email;
        this.nickname = nickname;
    }

    public User toEntity() {
        return User.of(
                this.email, this.nickname, this.phoneNumber
        );
    }

    static public UserInfoOauthDto of(){
        return new UserInfoOauthDto();
    }

    static public UserInfoOauthDto of(String email, String nickname, String phoneNumber) {
        return new UserInfoOauthDto(email, nickname, phoneNumber);
    }

    static public UserInfoOauthDto of(User user) {
        return new UserInfoOauthDto(user.getEmail(), user.getNickname(), user.getPhoneNumber());
    }

    static public UserInfoOauthDto of(String email, String nickname) {
        return new UserInfoOauthDto(email, nickname);
    }
}



UserTokenDto

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import sportsmatchingservice.auth.domain.User;
import java.util.List;

@Getter
@NoArgsConstructor
public class UserTokenDto {

    private Long id;
    private String email;
    private String nickname;
    private List<String> roles;

    @Setter
    @JsonProperty("access_token")
    private String accessToken;

    @Setter
    @JsonProperty("refresh_token")
    private String refreshToken;

    private UserTokenDto(Long id, String email, String nickname, List<String> roles){
        this.id = id;
        this.email = email;
        this.nickname = nickname;
        this.roles = roles;
    }

    static public UserTokenDto of(User user) {
        return new UserTokenDto(user.getId(), user.getEmail(), user.getNickname(), user.getRoles());
    }

    static public UserTokenDto of(){
        return new UserTokenDto();
    }

}






Controller

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import sportsmatchingservice.auth.dto.UserSignupDto;
import sportsmatchingservice.auth.dto.UserTokenDto;
import sportsmatchingservice.auth.service.OauthKakaoService;
import sportsmatchingservice.auth.service.OauthNaverService;
import sportsmatchingservice.constant.ErrorCode;
import sportsmatchingservice.constant.dto.ApiDataResponse;
import sportsmatchingservice.auth.service.UserService;

@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

...
...


    @GetMapping("/oauth/params/{social}")
    public ApiDataResponse getOauthParams(@PathVariable("social") String social) {
        if (social.equals("kakao")){
            return ApiDataResponse.of(ErrorCode.OK, oauthKakaoService.getParameters());
        } else if (social.equals("naver")) {
            return ApiDataResponse.of(ErrorCode.OK, oauthNaverService.getParameters());
        } else {
            return ApiDataResponse.of(ErrorCode.INTERNAL_ERROR, null);
        }
    }

    @RequestMapping("/oauth/tokens/{social}")
    public ApiDataResponse getUserTokenDto(@PathVariable("social") String social,
                                           @RequestParam String code,
                                           @RequestParam(required = false) String state) {
        if (social.equals("kakao")) {
            UserTokenDto userTokenDto = oauthKakaoService.getUserToken(code);
            return ApiDataResponse.of(ErrorCode.OK, userTokenDto);
        } else if (social.equals("naver")) {
            UserTokenDto userTokenDto = oauthNaverService.getUserToken(code, state);
            return ApiDataResponse.of(ErrorCode.OK, userTokenDto);
        } else {
            return ApiDataResponse.of(ErrorCode.INTERNAL_ERROR, null);
        }
    }
}
  • 카카오는 state가 선택 값이고, 네이버는 필수 값이기에 @RequestParam(required = false) String state 으로 state 파라미터 설정






Service

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import sportsmatchingservice.auth.domain.User;
import sportsmatchingservice.auth.dto.OauthTokenDto;
import sportsmatchingservice.auth.dto.UserInfoOauthDto;
import sportsmatchingservice.auth.dto.UserTokenDto;
import sportsmatchingservice.auth.jwt.JwtTokenizer;
import sportsmatchingservice.auth.repository.UserRepository;
import sportsmatchingservice.auth.utils.CustomAuthorityUtils;

import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Service
public class OauthNaverService {
    private final UserRepository userRepository;
    private final CustomAuthorityUtils authorityUtils;
    private final JwtTokenizer jwtTokenizer;

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    @Getter
    @Value("${social.naver.params.clientId}")
    private String clientId;

    @Getter
    @Value("${social.naver.params.clientSecret}")
    private String clientSecret;

    @Getter
    @Value("${social.naver.path.redirectUri}")
    private String redirectUri;

    @Getter
    @Value("${social.naver.path.userInfoUrl}")
    private String userInfoUrl;

    @Getter
    @Value("${social.naver.path.tokenUrl}")
    private String tokenUrl;

    public OauthNaverService(UserRepository userRepository, CustomAuthorityUtils authorityUtils, JwtTokenizer jwtTokenizer) {
        this.userRepository = userRepository;
        this.authorityUtils = authorityUtils;
        this.jwtTokenizer = jwtTokenizer;
    }

    public UserTokenDto getUserToken(String code, String state) {
        String accessToken = getAccessToken(code, state);
        UserInfoOauthDto userInfoOauthDto = getUserInfo(accessToken);

        return setUserTokenDto(userInfoOauthDto);
    }

    public HashMap<String, String> getParameters() {
        HashMap<String, String> params = new HashMap<>();
        params.put("clientId", getClientId());
        params.put("redirectUri", getRedirectUri());
        params.put("state", generateState());
        return params;
    }

    public String getAccessToken(String authorizationCode, String state) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        LinkedMultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.set("grant_type", "authorization_code");
        params.set("client_id", getClientId());
        params.set("client_secret", getClientSecret());
        params.set("code", authorizationCode);
        params.set("state", state);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<String> response = restTemplate
                .postForEntity(getTokenUrl(), request, String.class);

        try {
            return objectMapper.readValue(response.getBody(), OauthTokenDto.class).getAccessToken();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    public UserInfoOauthDto getUserInfo(String accessToken) {
        String response = requestUserInfo(accessToken);

        try {
            JsonNode jsonNode = objectMapper.readTree(response);

            String email = jsonNode.get("response").get("email").asText();
            String nickname = jsonNode.get("response").get("nickname").asText();
            String phoneNumber = jsonNode.get("response").get("mobile").asText();

            return UserInfoOauthDto.of(email, nickname, phoneNumber);
        } catch (JsonMappingException e) {
            e.printStackTrace();
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return UserInfoOauthDto.of();
    }

    public String requestUserInfo(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.set("Authorization", "Bearer " + accessToken);

        LinkedMultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

        String response = restTemplate.postForEntity(getUserInfoUrl(), request, String.class).getBody();

        return response;
    }

    public UserTokenDto setUserTokenDto(UserInfoOauthDto userInfoOauthDto) {
        Optional<User> optionalUser = userRepository.findByEmail(userInfoOauthDto.getEmail());
        User user;

        if (optionalUser.isPresent()) {
            user = optionalUser.get();
        } else {
            user = userInfoOauthDto.toEntity();
            user.setRoles(authorityUtils.createRoles(user.getEmail()));
            userRepository.save(user);
        }

        UserTokenDto userTokenDto = UserTokenDto.of(user);
        setTokens(userTokenDto);
        return userTokenDto;
    }

    public void setTokens(UserTokenDto userTokenDto) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", userTokenDto.getEmail());
        claims.put("roles", userTokenDto.getRoles());

        String subject = userTokenDto.getEmail();
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        Date accessTokenExpiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
        Date refreshTokenExpiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, accessTokenExpiration, base64EncodedSecretKey);
        String refreshToken = jwtTokenizer.generateRefreshToken(subject, refreshTokenExpiration, base64EncodedSecretKey);

        userTokenDto.setAccessToken(accessToken);
        userTokenDto.setRefreshToken(refreshToken);
    }
	
    // state 생성 메서드
    public String generateState() {
        SecureRandom random = new SecureRandom();
        String state = new BigInteger(130, random).toString();
        return state;
    }
}






추가한 코드들만 기록했기 때문에 이 것만 보면 이해가 안갈 수 있다.
네이버 소셜 로그인 구현 시 참고하시는 분들을 위해 혹시 몰라 프로젝트 깃허브 링크를 기재하고 마무리 하려고 한다.

카카오, 네이버 소셜 로그인을 구현한 프로젝트 코드 (깃허브)



main 브랜치에서 코드를 확인할 수 없다면 develop 브랜치의 코드를 참고할 것






참고 자료


네이버 로그인 개발가이드

Web 애플리케이션

네이버 로그인 인증 값 중 state값 검증 문의

[Spring Security] 스프링 부트 OAuth2를 이용한 네이버 계정 로그인 (직접 구현)



아래는 구현에 실패했지만 참고했던 OAuth2 client 관련 자료들(나중에 다시 시도해볼 것이다)

스프링 부트 OAuth2-client를 이용한 소셜(구글, 네이버, 카카오) 로그인 하기

[Spring] 스프링으로 OAuth2 로그인 구현하기2 - 네이버

[Spring Security] OAuth 네이버 로그인하기

07. 스프링 시큐리티 (Spring Security) - OAuth2 를 이용한 네이버, 카카오, 구글 인증 + JWT

Spring Boot 게시판 OAuth 2.0 네이버 로그인 구현

0개의 댓글