본문 바로가기
Study in Bootcamp/Main Project

[Main Project] Day 11 : 소셜 로그인 구현 및 결제 시도와 API 정하기

by Bhinney 2022. 11. 23.

본 글은 프로젝트를 기록하기 위해 적은 글 입니다.

수정이 될 수 있으며, 정확하지 않을 수 있습니다.


📌 소셜 로그인 구현

🚨 문제의 발생

 : Spring Security의 FilterChain으로 구현한 oauthLogin에서 아래 주석처리한 부분인 .userInfoEndpoint()가 계속 빨간 에러가 떴다. 해당 부분을 주석처리하고 작업을 진행했다. 하지만 계속해서 controller로 접근이 안되고, 리다이렉트로 넘어가 로그인을 성공해도 DB에는 저장이 되지 않았다. 그래서 필터가 아닌 Controller에서 일일이 써서 작업을 시도해보았다.

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	http
		.headers().frameOptions().sameOrigin()
		.and()
		.csrf().disable()
		.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		.and()
		.formLogin().disable()
		.httpBasic().disable()
		.apply(new JwtSecurityConfig(securityProvider))
		.and()
		.authorizeHttpRequests(authorize -> authorize
				.anyRequest().permitAll()
		)
		.oauth2Login()
		.authorizationEndpoint()
		.authorizationRequestRepository(oAuth2AuthorizationRequestRepository())
		.and()
		.successHandler(oAuth2AuthenticationSuccessHandler())
		.failureHandler(oAuth2AuthenticationFailureHandler())
		.and()
		// .userInfoEndpoint()
		// .userService(customOAuth2Service)
		// .and()
		.exceptionHandling()
		.authenticationEntryPoint(new MemberAuthenticationEntryPoint())
		.accessDeniedHandler(new MemberAccessDeniedHandler());

	return http.build();
}

💡나름의 해결?

: 하나씩 처음부터 차근히 시도해보았다. config의 소셜 부분을 전부 주석처리하고, Controller에서 하나씩 불러와보았다.


1️⃣ 가장 먼저, 리다이렉트 페이지에서 성공할 시 code를 불러와보았다.

  • Controller에서 아래의 리다이렉트 코드를 입력한다.
  • https://kauth.kakao.com/oauth/authorize?client_id={KAKAO_REST_API_KEY}&redirect_uri={REDIRECT_URL}&response_type=code
  • 그리고 본인의 api key와 설정해준 리다이렉트 url을 입력한 후, 해당 페이지에 가서 로그인을 시도했다.
  • 그랬더니 아래의 사진처럼 코드가 잘 들어왔다.
@GetMapping("/login/oauth2/code/kakao")
public String KakaoCode(@RequestParam("code") String code) {
   return "카카오 로그인 인증완료, code: "  + code;
}


2️⃣ 얻은 코드를 파라미터로 넣어 토큰을 생성하고, 회원을 저장하였다.

  • 해당 코드를 통해 카카오 서버의 accessToken을 얻고, 그 코드로 회원의 정보를 얻어와 나의 서버의 accessToken과 refreshToken을 발급하였다.
  • 아래의 코드처럼 카카오의 서버로 엑세스 토큰을 받아, 그 아래의 코드에서 정보를 가져와 서버에 회원으로 저장하였다.
public KakaoToken getAccessToken(String code) {

   /* RequestParam */
   MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
   param.add("grant_type", "authorization_code");
   param.add("client_id", clientId);
   param.add("redirect_uri", redirectUri);
   param.add("code", code);
   param.add("client_secret", clientSecret);

   /* 요청 */
   WebClient wc = WebClient.create(accessTokenUri);

   String response = wc.post()
      .uri(accessTokenUri)
      .body(BodyInserters.fromFormData(param))
      .header("Content-type","application/x-www-form-urlencoded;charset=utf-8")
      .retrieve()
      .bodyToMono(String.class)
      .block();

   /* Json 으로 변환 */
   ObjectMapper objectMapper = new ObjectMapper();
   KakaoToken kakaoToken = null;

   try {
      kakaoToken = objectMapper.readValue(response, KakaoToken.class);
   } catch (JsonProcessingException e) {
      e.printStackTrace();
   }

   return kakaoToken;
}
public KakaoProfile findProfile(String token) {
   WebClient wc = WebClient.create(userInfoUri);

   String response = wc.post()
      .uri(userInfoUri)
      .header("Authorization", "Bearer " + token)
      .retrieve()
      .bodyToMono(String.class)
      .block();

   ObjectMapper objectMapper = new ObjectMapper();
   KakaoProfile kakaoProfile = null;

   try {
      kakaoProfile = objectMapper.readValue(response, KakaoProfile.class);

   } catch (JsonProcessingException e) {
      e.printStackTrace();
   }

   return kakaoProfile;
}
public Member saveMember(String access_token) {
   KakaoProfile profile = findProfile(access_token);
   Member member = memberRepository.findMemberByEmail(profile.getKakao_account().getEmail());

   /* 첫 이용자 강제 회원가입 */
   if(member == null) {
      member = Member.builder()
         .socialId(profile.getId())
         .password(null)
         .name(profile.getKakao_account().getProfile().getNickname())
         .email(profile.getKakao_account().getEmail())
         .role("SOCIAL")
         .roles(List.of("SOCIAL"))
         .providerType(ProviderType.KAKAO)
         .build();

      memberRepository.save(member);
   }

   return member;
}

3️⃣ 저장한 회원으로 서버 토큰을 생성하고 저장하였다.

  • 위에처럼 저장하면, DB에 회원이 저장된다.
  • 해당 저장 정보를 바탕으로 provider에서 해당 회원의 토큰을 생성하고 리프레시 토큰을 저장하였다.
  • 그러면 아래처럼 나의 웹 서버의 토큰이 생성되고, DB에도 잘 저장된 것을 확인할 수 있다.


📌 소셜 로그인 시, 권한 부여 

🚨 원인

: 이렇게 모든 걸 일일이 하게 된 원인은 바로 권한 부여, 이것 때문이다. 현재 작업 중인 웹 애플리케이션은 판매자와 소비자의 페이지가 나눠져 있다. 따라서 접근할 수 있는 API가 다르고, 각각 판매자와 소비자의 아이디가 다르게 부여된다. 또한 이 역할(권한)은 수정할 수 없게 설계되어 있다.

하지만 소셜로 로그인을 하게 되면, 이 부분을 어떻게 선택해야 할 지 어려웠다. 그래서 SOCIAL이라는 역할(권한)을 하나 생성하고, 해당 역할(권한)을 가진 사람만 접근할 수 있는 API를 만들어 그곳에서 소비자와 판매자를 선택할 수 있게 하였다. 또한 이 API는 소셜의 권한을 가진 사람만 접근할 수 있고, 선택하였다면 권한은 그 이후로 바꿀 수 없게 설계하였다.

 

  • 아래의 사진에 나온 url처럼 하나의 수정 api를 만들고 소셜의 역할을 가진 사람만 접근할 수 있게 하였다.
  • 또한 해당 권한을 바꾸면 토큰이 새로 생성하도록 하였다.
  • 토큰에는 이 정보들이 저장되어야 하기 때문이다.
  • 그래서 원래의 토큰으로 두번 접근하거나, 바뀐 토큰으로 접근하면 그 아래의 사진처럼 잘못된 접근이라는 메세지가 등장하게 하였다.

왼쪽은 역할(권한)이 소셜이고, 오른쪽은 역할(권한)이 소비자이다.

 

  • 코드로 살펴보면, 아래의 코드처럼 소셜이라는 하나의 제한을 설정해준 것을 확인 할 수 있다.
  • 그리고 그 아래의 코드처럼 권한 수정을 하였다.
  • 그리고 해당 권한에 맞추어 토큰을 재발급해주었다.
.authorizeHttpRequests(authorize -> authorize
      .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()

      /* 회원 관련 접근 제한 */
      .antMatchers(HttpMethod.POST, "/members/signup").permitAll()
      .antMatchers(HttpMethod.POST, "/login").permitAll()
      .antMatchers(HttpMethod.POST, "/login/oauth").permitAll()
      .antMatchers(HttpMethod.GET, "/members/client/**").hasRole("CLIENT")
      .antMatchers(HttpMethod.PATCH, "members/client/**").hasRole("CLIENT")
      .antMatchers(HttpMethod.GET,"/members/seller/**").hasRole("SELLER")
      .antMatchers(HttpMethod.PATCH, "/members/seller/**").hasRole("SELLER")
      .antMatchers(HttpMethod.DELETE, "/members/**").hasAnyRole("CLIENT", "SELLER")

      /* 소셜 수정 권한 접근 */
      .antMatchers(HttpMethod.PATCH, "/social/**").hasRole("SOCIAL")

      .anyRequest().permitAll()
)
// 컨트롤러 클래스
@PatchMapping("/social/{member_id}")
public ResponseEntity patchSocial(@PathVariable("member_id") long memberId,
		@RequestBody SocialPatchDto patchDto) {
        
    // url의 멤버 아이디와 들어온 멤버 아이디가 일치하는 지 확인하는 메서드
	securityService.correctMember(memberId, patchDto.getMemberId());
	Member member = securityService.updateSocial(patchDto);
	LoginRequestDto request = new LoginRequestDto(member.getEmail(), member.getPassword());

	/* 여기서 토큰 재발급 */
	TokenDto tokenDto = securityService.socialLogin(request);
	HttpHeaders httpHeaders = setHeader(tokenDto);

	if (member.getRole().equals("SELLER")) {
		LoginResponse.Seller response = mapper.loginSellerResponseDto(member, tokenDto);

		return new ResponseEntity<>(response,httpHeaders, HttpStatus.OK);
	} else if (member.getRole().equals("CLIENT")) {
		LoginResponse.Cilent response = mapper.loginClientResponseDto(member, tokenDto);

		return new ResponseEntity<>(response, httpHeaders, HttpStatus.OK);
	}
	throw new BusinessLogicException(ExceptionCode.WRONG_ACCESS);
}


// 서비스 클래스
public Member updateSocial(SocialPatchDto patchDto) {

   Member member =
      memberRepository.findById(patchDto.getMemberId())
         .orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
   checkSocialRole(member.getRole());

   if (patchDto.getRole().equalsIgnoreCase("CLIENT")) {
      member.setRole("CLIENT");
      member.setRoles(List.of("CLIENT"));
      member.setClient(new Client());
   } else if (patchDto.getRole().equalsIgnoreCase("SELLER")) {
      member.setRole("SELLER");
      member.setRoles(List.of("SELLER"));
      member.setSeller(new Seller());
   } else {
      throw new RuntimeException("수정할 수 없습니다.");
   }

   return member;
}

 

🥲 아쉬운 부분

: 사실 filter로 해당 부분을 해내지 못한게 너무 아쉽다. 우선 이렇게 구현하고 계속해서 filter로 할 수 있는지 시도해 볼 생각이다. 나의 생각과 멘토님의 조언 모두 filter로 해보는 게 더 좋은 코드라고 가리키기 때문이다. 현재의 코드는 약간 아래의 사진같다.... 돌아가긴 돌아가지만.....🥲


📌 결제 시도 및 구현 그리고 API 정하기

: 이제 후순위로 밀어두었지만, 정말 정말 해내고 싶은 결제 파트를 시도해보았다. 여러 PG의 API를 찾아보고 얻어보려고 하였으나 어려웠다. 첫 번째로, 지금 프로젝트를 하는 것은 사업자가 없기 때문에 가입하는 것 조차도 어려웠다. 두 번째로는 해당 결제 시스템을 실제처럼 구현해놓은(?) 서비스들도 찾아보았는데, 거의 프론트에서 하고 마지막에만 백에서 정보를 받는 것 같았다. 그래서 소셜 로그인으로 만들어 두었던 카카오를 한 번 더 이용해보기로 하였다.

 

작업 전에 프론트 팀원 분과 함께 해당 부분을 프론트에서 할지 백에서 할지, 그리고 REST API는 어떻게 하고 응답 바디와 요청은 어떻게 할지를 상의하였다.

카카오의 ADMIN 키로 QR을 불러와 결제를 할 수 있다고 하여, 해당 레퍼런스를 보고 입력해보았는데, RestTemplate의  body를 생성하지 못했다.

팀원 분도 같은 레퍼런스로 주문 연동 없이 해보았을 때에는 QR코드로 이동하는 url이 잘 나오신다고 하셔서 주문 연동 후에도 잘 나오는지 확인해 보신다고 하셨다. 팀원 분의 결과를 보고 내일 다시 어디가 문제인지 잡아야 할 것 같다.

 

public ReadyResponse payReady(Ord ord) {
   Member member = memberService.findVerifiedMember(ord.getClient().getMember().getMemberId());

   /* 주문 불러오기 */
   if(member.getClient().getClientId() != ord.getClient().getClientId()) {
      throw new RuntimeException("잘못된 유저입니다.");
   }

   /* 카카오가 요구한 결제 요청? */
   MultiValueMap<String, Object> map = new LinkedMultiValueMap<>();
   map.add("cid", "TC0ONETIME");  /* 테스트 용 가맹점 코드 */
   map.add("partner_order_id", ord.getOrdId()); /* 가맹점 주문번호 -> String 형 */
   map.add("partner_user_id", "17farm");
   map.add("item_name", ord.getProduct().getBoard().getTitle());
   map.add("quantity", ord.getTotalQuantity()); /* 개수는 상수형 */
   map.add("total_amount", ord.getTotalPrice()); /* 상품 총액 */
   map.add("tax_free_amount", 0); /* 상품 비과세 금액 */
   map.add("approval_url", "http://localhost:8080/order/pay/completed"); /* 결제 승인 url */
   map.add("cancel_url", "http://localhost:8080/order/pay/cancel"); /* 결제 취소 url */
   map.add("fail_url", "http://localhost:8080/order/pay/fail"); /* 결제 실패 url */

   HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(map, this.getHeaders());

   RestTemplate template = new RestTemplate();
   String url = "https://kapi.kakao.com/v1/payment/ready";
   
   /* 여기서 body가 생성이 안된다고 나옴 */
   ReadyResponse readyResponse = template.postForObject(url, requestEntity, ReadyResponse.class);
   return readyResponse;
}


🔥내일 할 일

  • 결제 문제 찾기
  • Refresh로 토큰 재발급 구현하는 것도 다시 수정하면서 해보기
  • Options HttpMethod 공부해보기 -> 이유와 해결 방안?

댓글