진행하는 사이드 프로젝트에서 네이버 소셜 로그인을 구현하게 되었다. 다른 백엔드 개발자분은 카카오를 구현하시기로 하고 각자 진행했는데 나는 OAuth2 client
로 삽질하면서 구현을 못하고 있었다.
그러던 중에 다른 팀원분이 카카오 소셜 로그인 구현하신 것을 보고 크게 반성하며 네이버 소셜 로그인을 구현하였다. OAuth2 client
로 구현하는 것에 집착한 나머지 다른 방법을 사용할 생각을 못했던 것 같다.
다른 팀원 분은 직접 구현하셨고, 그 코드를 기반으로 네이버 로그인도 구현할 수 있었다.
그래서 그 내용에 대해 정리해보려고 한다.
개발 환경
Spring Boot 2.7.4
Java 11
IntelliJ
소셜 로그인을 위해서는 네이버에서 API 키를 발급 받아야한다.
Naver Developers에서 발급 가능하다.
위 링크를 통해 Naver Developers에 접속한다.
Application 탭 - 애플리케이션 등록 탭을 누른다.
애플리케이션 이름: 사용하고자 하는 애플리케이션 이름을 작성한다 (프로젝트명, 브랜드 명 등).
사용 API: 네이버 로그인을 선택한다.
사용자에게 필수적으로 수집할 정보(필수)와 선택적으로 수집할 정보(추가)를 선택한다.
이후에 수정 가능
로그인 오픈 API 서비스 환경: 만들고자 하는 서비스의 환경을 선택한다. 나는 웹 애플리케이션을 제작하므로 PC 웹을 선택했다.
이후에 수정 가능
서비스 URL: http://localhost:8080, https://www.naver.com 과 같이 서비스 URL을 기재한다.
네이버 로그인 Callback URL: authorization code, 토큰, 사용자 정보를 받을 URL을 추가한다.
이후에 수정 가능
이외에도 로고, 서비스 약관 정보 등을 추가할 수 있다.
코드를 구현하기 전에 네이버 로그인 개발가이드를 참고하는 것이 좋다.
네이버에서 소셜 로그인을 구현하기 위한 가이드를 제시한 것이므로 가장 먼저 보는 것을 추천한다.
또한 공식 문서를 봐야 제공되는 URI나 파라미터를 알 수 있기 때문에 반드시 한 번은 봐야한다.
참고한 자료들은 하단에 모두 링크해두었으니 필요에 따라 참고하면 될 것 같다.
...
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 기재
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;
}
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);
}
}
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();
}
}
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
파라미터 설정
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
브랜치의 코드를 참고할 것
[Spring Security] 스프링 부트 OAuth2를 이용한 네이버 계정 로그인 (직접 구현)
아래는 구현에 실패했지만 참고했던 OAuth2 client 관련 자료들(나중에 다시 시도해볼 것이다)
스프링 부트 OAuth2-client를 이용한 소셜(구글, 네이버, 카카오) 로그인 하기
[Spring] 스프링으로 OAuth2 로그인 구현하기2 - 네이버
[Spring Security] OAuth 네이버 로그인하기
07. 스프링 시큐리티 (Spring Security) - OAuth2 를 이용한 네이버, 카카오, 구글 인증 + JWT