본문 바로가기
Team project/[파이널] 개발자 매칭 서비스 - Perfectfolio

[4~7일차] 네이버 로그인 연동 구현하기

by 개발자공부 2024. 8. 23.
사전 정보 1 - OAuth (Open Authorization)이란?

 OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹 사이트 상에 존재하는 개인정보에 웹 사이트나 애플리케이션이 접근할 수 있도록 권한을 부여할 수 있는 공통적인 수단으로서 사용되는 접근 위임을 위한 개방형 표준입니다.

 

 애플리케이션을 이용할 때 아이디, 비밀번호로 가입하지 않고 신뢰할 수 있는 외부 애플리케이션(Google, Naver, Kakao, Github 등...)에 Open API에 아이디, 비밀번호를 입력하여 해당 애플리케이션이 인증 과정을 처리해주는 방식입니다.

 

 REST API (Representational State Transfer API) 이기 때문에 양식은 비슷합니다.

 

사전 정보 2 - JWT란?

  Json Web Token을 줄인 말입니다. 인증에 필요한 정보들을 Json 객체에 담은 후 비밀키로 서명한 토큰입니다. 이는 인터넷 표준 인증 방식이며, 공식적으로 인증(Authentication)과 권한허가(Authorization) 방식으로 사용됩니다.

 


0. naver developers

https://developers.naver.com/main/

 

NAVER Developers

네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음

developers.naver.com

 

1. 애플리케이션 > 애플리케이션 등록

약관에 동의하고, 휴대전화 본인인증을 거친 후 애플리케이션 등록을 진행합니다.

테스트용이라서 애플리케이션 이름은 demo 입니다. 사용할 API 종류를 선택합니다.
네이버 로그인을 선택했습니다.
네이버의 경우 서비스 환경을 여러 개 추가할 수 있다.
서비스 URL과 Callback URL을 등록했습니다. 클라이언트가 네이버 로그인을 요청하면 인증 및 정보 동의 화면을 보여줍니다. 이에 동의하면 콜백해줄 Redirect URL을 작성하는 것입니다.

 

2. 애플리케이션 등록 확인 및 클라이언트 아이디와 시크릿 확인하기

Client ID, Client Secret로 인가 요청, 액세스 토큰 발급, 사용자 정보 조회 등을 수행할 수 있습니다.

! 해당 정보는 내 애플리케이션을 구분해주는 중요한 정보이므로 반드시 안전하게 보관해야 합니다. 네이버 로그인 연동 과정에서 활용되는 정보이기 때문에 잘못된 Client ID, Client Secret를 사용하면 당연히 연동에 실패합니다. 한 번 발급된 Client ID는 변경할 수 없습니다. 그에 반해 Client Secret 정보는 개발자 센터를 통해 재발급 받을 수 있습니다. Client Secret는 유출이 의심될 경우 재발급을 받아서 도용을 방지할 수 있습니다.

 

3. 로그인 버튼 가이드

https://developers.naver.com/docs/login/bi/bi.md

 

로그인 버튼 사용 가이드 - LOGIN

네이버 로그인은 애플리케이션에 사용할 수 있는 네이버 로그인 버튼 기본 이미지를 제공합니다. 애플리케이션의 상황에 맞게 버튼 이미지의 디자인을 변경할 수 있지만 네이버 고유의 아이덴

developers.naver.com

 

 

4.  개발 가이드를 참고하여 네이버 로그인 연동 URL을 생성합니다.

 

CSRF 공격을 방지하기 위해 state 값을 랜덤으로 자체 생성해줘야 합니다.

 

5. 4번에서 URL 요청시 Callback 정보입니다.

6. 접근 토큰 발급 요청시 필요한 정보입니다.

https://nid.naver.com/oauth2.0/token?grant_type=authorization_code&client_id=jyvqXeaVOVmV&client_secret=527300A0_COq1_XV33cf&code=EIc5bFrl4RibFls1&state=9kgsGTfH4j7IyAkg

 

그에 대한 응답 정보입니다.

 

7. 응답받은 액세스 토큰으로 프로필 API를 호출합니다.

curl  -XGET "https://openapi.naver.com/v1/nid/me" \
      -H "Authorization: Bearer AAAAPIuf0L+qfDkMABQ3IJ8heq2mlw71DojBj3oc2Z6OxMQESVSrtR0dbvsiQbPbP1/cxva23n7mQShtfK4pchdk/rc="

 

성공한다면 사전에 선택했던 정보가 출력됩니다.

 


코드

 

패키지 구조

더보기

동의 화면을 출력하기 위한 링크입니다. 
ClientId등의 값은 yml에 선언했습니다.

<a href="https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=${naverClientId}&state=${state}&redirect_uri=${naverRedirectUri}">
	<img alt="네이버 로그인 버튼" class="social--btn" src="/images/btnG_완성형.png">
</a>
더보기

yml 파일

naver:
  client-id: 61124mn0Y_vuN0I9gWUm # 발급 받은 Client ID
  client-secret: r8vgxA9kTU # 발급 받은 Client Secret
  redirect-uri: http://localhost:8080/user/naver # 
  client-authentication-method: client_secret_post
#            scope:
#              - name
#              - email
#              - profile_image
  client-name: Naver

더보기

유저 컨트롤러

- 로그아웃과 연동해제는 세션을 삭제하고, 연동해제 같은 경우는 DB 조작 메소드만 추가했습니다.

- 회원가입, 로그인 페이지 접속시 소셜 로그인 url에 필요한 정보를 Model로 내려줬습니다.

@Controller
@RequestMapping("/user")
@RequiredArgsConstructor

public class UserController {
@Autowired
	private HttpSession session;
	@Value("${naver.client-id}")
	private String naverClientId;
	@Value("${naver.client-secret}")
	private String naverClientSecret;
	@Value("${naver.redirect-uri}")
	private String naverRedirectUri;
	private String state;

	/**
	 * 네이버 소셜 로그인시 필요한 state CSRF 공격을 방지하기 위한 랜덤 문자열을 생성합니다.
	 * 
	 * @return
	 */
	public String generateState() {
		SecureRandom random = new SecureRandom();
		return new BigInteger(130, random).toString(32);
	}
    
    /**
	 * 로그인 페이지 http://localhost:8080/user/sign-in
	 * 
	 * @return signIn.jsp
	 */
	@GetMapping("/sign-in")
	public String signInPage(Model model) {
		// 상태 토큰으로 사용할 랜덤 문자열 생성
		String state = generateState();
		model.addAttribute("naverClientId", naverClientId);
		model.addAttribute("naverRedirectUri", naverRedirectUri);
		model.addAttribute("state", state);

		model.addAttribute("kakaoRestApiKey", kakaoClientId);
		model.addAttribute("kakaoRedirectUri", kakaoRedirectUri);

		model.addAttribute("googleClientId", googleClientId);
		model.addAttribute("googleRedirectUri", googleRedirectUri);
		return "user/signIn";
	}
    

/**
	 * 네이버 소셜 로그인 로직 처리 요청
	 * 
	 * @param code
	 * @param state
	 * @return
	 */
	@GetMapping("/naver")
	public String getMethodName(@RequestParam(name = "code", required = false) String code,
			@RequestParam(name = "state", required = false) String state, Model model) {
		// Access Token 발급 요청
		RestTemplate naverRt1 = new RestTemplate();
		HttpHeaders header1 = new HttpHeaders();
		MultiValueMap<String, String> params1 = new LinkedMultiValueMap<>();
		params1.add("grant_type", naverGrantTypeIssue);
		params1.add("client_id", naverClientId);
		params1.add("client_secret", naverClientSecret);
		params1.add("code", code);
		params1.add("state", state);
		HttpEntity<MultiValueMap<String, String>> reqNaverToken = new HttpEntity<>(params1, header1);
		ResponseEntity<NaverToken> response = naverRt1.exchange(Define.NAVER_REQUEST_ACCESSTOKEN_URL, HttpMethod.POST,
				reqNaverToken, NaverToken.class);
		accessToken = response.getBody().getAccess_token();

		// Access Token으로 유저 정보 받아오기
		RestTemplate rt2 = new RestTemplate();
		HttpHeaders header2 = new HttpHeaders();
		header2.add("Authorization", "Bearer " + response.getBody().getAccess_token());
		HttpEntity<MultiValueMap<String, String>> naverProfileRequest = new HttpEntity<>(header2);
		ResponseEntity<NaverUserInfoDTO> response2 = rt2.exchange(Define.NAVER_REQUEST_USERINFO_URL, HttpMethod.POST,
				naverProfileRequest, NaverUserInfoDTO.class);
		NaverUserInfoDTO naverUserInfoDTO = response2.getBody();
		naverUserInfoDTO.genderType(naverUserInfoDTO.getResponse().getGender());

		SignUpDTO signUpDTO = SignUpDTO.builder().username(naverUserInfoDTO.getResponse().getName())
				.userId(naverUserInfoDTO.getResponse().getId()).userPassword(Define.SOCIAL_PASSWORD_NAVER)
				.userNickname(naverUserInfoDTO.getResponse().getNickname())
				.userEmail(naverUserInfoDTO.getResponse().getEmail())
				.userBirth(naverUserInfoDTO.getResponse().getBirthyear() + "-"
						+ naverUserInfoDTO.getResponse().getBirthday())
				.userGender(naverUserInfoDTO.getResponse().getGender())
				.userTel(naverUserInfoDTO.getResponse().getMobile()).userSocialType(Define.SOCIAL_TYPE_IS_NAVER)
//				.userOriginProfileImage(naverUserInfoDTO.getResponse().getProfile_image())
				.build();

		// 최초 소셜 사용자인지 판별
		PrincipalDTO principalDTO = userService.searchUserEmail(signUpDTO.getUserEmail());
		if (principalDTO == null) {
			userService.createUser(signUpDTO);
		}

		session.setAttribute("principal", principalDTO);

		return "main";
	}
    
    }
더보기
public class Define {

	// 이미지 관련
		public static final String UPLOAD_FILE_DERECTORY = "C:\\Users\\user\\git\\perfect_folio\\src\\main\\resources\\static\\images/";
		public static final int MAX_FILE_SIZE = 1024 * 1024 * 20; // 20MB

	// REST api 요청 URL
	  public static final String NAVER_REQUEST_ACCESSTOKEN_URL = "https://nid.naver.com/oauth2.0/token";
	  public static final String NAVER_REQUEST_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
	  public static final String KAKAO_REQUEST_ACCESSTOKEN_URL = "https://kauth.kakao.com/oauth/token";
	  public static final String KAKAO_REQUEST_USERINFO_URL = "https://kapi.kakao.com/v2/user/me";
	  
	  public static final String GOOGLE_REQUEST_ACCESSTOKEN_URL = "https://oauth2.googleapis.com/token";
	  public static final String GOOGLE_REQUEST_USERINFO_URL = "https://www.googleapis.com/userinfo/v2/me?access_token=";
	  
	  
	// 소셜 로그인 타입
		public static final String SOCIAL_TYPE_IS_LOCAL = "local";
		public static final String SOCIAL_TYPE_IS_NAVER = "naver";
		public static final String SOCIAL_TYPE_IS_GOOGLE = "google";
		public static final String SOCIAL_TYPE_IS_KAKAO = "kakao";
		
	// 소셜 로그인 비밀번호 할당
		public static final String SOCIAL_PASSWORD_GOOGLE= "OAuth_Google";
		public static final String SOCIAL_PASSWORD_NAVER = "OAuth_Naver";
		public static final String SOCIAL_PASSWORD_KAKAO = "OAuth_Kakao";
}
더보기

DTO

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class NaverToken {
	private String access_token;
	private String refresh_token;
	private String token_type;
	private Integer expires_in;
	private String error;
	private String error_description;
	private String result;
}

 

네이버 성별 반환값은 M, F, U 라서 가공하는 메소드를 넣었습니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class NaverUserInfoDTO {

	public String resultcode;
	public String message;
	public response response;

	@Data
	public class response {
		String id;
		String nickname;
		String name;
		String email;
		String gender;
		String age;
		String birthday;
		String profile_image;
		String birthyear;
		String mobile;
	}

	public String genderType(String gender) {
		if (gender.equals("M")) {
			response.setGender("남성");
//			naverUserInfoDTO.getResponse().setGender("남성");
		} else if (gender.equals("F")) {
			response.setGender("여성");
//			naverUserInfoDTO.getResponse().setGender("여성");
		}else {
			response.setGender("");
		}
		return gender;
	}
	
}
더보기

유저 서비스 클래스

- 클라이언트는 서비스 클래스에 진입할 수 없습니다. 컨트롤러에서 작업 처리가 끝나야 합니다.

- User 모델을 컨트롤러에 반환하면 데이터를 그대로 노출하는 것이기 때문에 세션에 내려줄 최소한의 값만 DTO에 담았습니다.

- MySQL DB에 이메일 컬럼은 Unique key 값입니다. 네이버에서 이용자 이메일을 제공해주기 때문에 이메일로 기존 유저인지 존재 여부를 조회합니다.

	/**
	 * 
	 * userEmail로 사용자 존재 여부 조회
	 * 
	 * @param String userEmail
	 * @return User
	 */
	public PrincipalDTO searchUserEmail(String userEmail) {
		User user = userRepository.findByUserEmail(userEmail);

		if (user != null) {
			PrincipalDTO principalDTO = PrincipalDTO.builder().id(user.getId()).username(user.getUsername())
					.userSocialType(user.getSocialType()).build();
			return principalDTO;
		}

		return null;
	}
더보기
@Mapper
public interface UserRepository {

	public int insert(User user); // 유저 정보를 user_tb에 입력
	public User findByUserEmail(String userEmail); // userEmail 기존 유저인지 판단
	public User findByUserId(String userId); // userId로 기존 유저인지 판단
	
	public User findByid(int id); // PK 값으로 유저 정보 찾기
	public int insertWithdraw(User user); // 탈퇴시 탈퇴 테이블에 입력
	public void delete(int id); // 탈퇴시 기존 테이블에서 삭제

}
더보기
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tenco.perfectfolio.repository.interfaces.UserRepository">
	
	<insert id="insert">
		INSERT INTO user_tb(user_name, user_id, user_password, user_nickname,
		user_email,user_birth,user_gender,user_tel,social_type)
		VALUES ( #{username}, #{userId}, #{userPassword}, #{userNickname},
		#{userEmail},#{userBirth},#{userGender},#{userTel},#{socialType})
	</insert>
	
	<insert id="insertWithdraw">
		INSERT INTO user_withdraw_tb(user_name, user_id, user_password, user_nickname,
		user_email,user_birth,user_gender,user_tel,social_type,created_at)
		VALUES ( #{username}, #{userId}, #{userPassword}, #{userNickname},
		#{userEmail},#{userBirth},#{userGender},#{userTel},#{socialType},#{createdAt})
	</insert>
	
	<!-- user_tb insert 이미지url 컬럼 포함 -->
<!--	<insert id="insert">
		INSERT INTO user_tb(user_name, user_id, user_password, user_nickname,
		user_email,user_birth,user_gender,user_tel,user_origin_profile_image,user_upload_profile_image,social_type)
		 VALUES ( #{username}, #{userId}, #{userPassword}, #{userNickname},
		#{userEmail},#{userBirth},#{userGender},#{userTel},#{userOriginProfileImage},#{userUploadProfileImage},#{userSocialType})
	</insert>-->
	
 	<select id="findByid">
 	SELECT * FROM user_tb WHERE id = #{id}
 	</select>

 	<select id="findByUserEmail">
 	SELECT * FROM user_tb WHERE user_email = #{userEmail}
 	</select>
 	
 	<select id="findByUserId">
 	SELECT * FROM user_tb WHERE user_id = #{userId}
	</select>
	
	<delete id="delete">
		DELETE FROM user_tb WHERE ID = #{id}
	</delete>
	
</mapper>

 

더보기
CREATE TABLE user_tb(
id int auto_increment primary key,
user_name varchar(255) not null,
user_id varchar(255) not null unique,
user_password varchar(255) NOT NULL,
user_nickname    varchar(20),
user_email    varchar(255)    NOT NULL  unique   ,
user_birth    varchar(100)    NOT NULL,
user_gender enum('남성','여성'),
user_tel    varchar(13),
created_at timestamp default now() not null,
social_type varchar(30) not null
);

추후에 프로필 사진을 넣고 검토 요청을 넣어야 다른 사람도 기능을 이용할 수 있습니다.