Skip to content

Commit

Permalink
Move from spring-gift-order (#73)
Browse files Browse the repository at this point in the history
  • Loading branch information
rbm0524 authored Jul 30, 2024
1 parent 68235b7 commit a6acbfa
Show file tree
Hide file tree
Showing 65 changed files with 4,084 additions and 1 deletion.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,33 @@
# spring-gift-point
# spring-gift-order
# 1단계 - 카카오 로그인

## 요구사항
### 기능 요구사항
1. 카카오 로그인을 통해 인가 코드를 받는다.
2. 인가 코드를 사용해 토큰을 받은 후 향후 카카오 API 사용을 준비한다.
3. 카카오계정 로그인을 통해 인증 코드를 받는다.
4. 토큰 받기를 읽고 액세스 토큰을 추출한다.
5. 앱 키, 인가 코드가 절대 유출되지 않도록 한다.
- 특히 시크릿 키는 GitHub나 클라이언트 코드 등 외부에서 볼 수 있는 곳에 추가하지 않는다.
6. 오류 처리
레퍼런스와 문제 해결을 참고하여 발생할 수 있는 다양한 오류를 처리한다.

# 2단계 - 주문하기

## 요구사항
### 기능 요구사항
1. 카카오톡 메시지 API를 사용하여 주문하기 기능을 구현한다.
2. 주문할 때 수령인에게 보낼 메시지를 작성할 수 있다.
3. 상품 옵션과 해당 수량을 선택하여 주문하면 해당 상품 옵션의 수량이 차감된다.
4. 해당 상품이 위시 리스트에 있는 경우 위시 리스트에서 삭제한다.
5. 나에게 보내기를 읽고 주문 내역을 카카오톡 메시지로 전송한다.
6. 메시지는 메시지 템플릿의 기본 템플릿이나 사용자 정의 템플릿을 사용하여 자유롭게 작성한다.

# 3단계 - API 문서 만들기

## 요구사항
### 기능 요구사항
API 사양에 관해 클라이언트와 어떻게 소통할 수 있을까? 어떻게 하면 편하게 소통할 수 있을지 고민해 보고 그 방법을 구현한다.
1. API 문서를 만들어 클라이언트에게 제공한다.
2. API 문서는 Swagger 또는 restDoc을 사용하여 만든다.
3. API 문서에는 API 명세, 요청, 응답, 예외 처리 등이 포함되어야 한다.
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
implementation 'org.springframework.plugin:spring-plugin-core:3.0.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
runtimeOnly 'com.h2database:h2'
compileOnly 'io.jsonwebtoken:jjwt-api:0.11.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-inline:4.9.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

}

tasks.named('test') {
Expand Down
80 changes: 80 additions & 0 deletions src/main/java/gift/Annotation/LoginMemberArgumentResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package gift.Annotation;

import gift.Model.MemberDto;
import gift.Service.MemberService;
import gift.Utils.JwtUtil;
import gift.Validation.KakaoTokenValidator;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import java.util.Optional;

@Component
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
private final MemberService memberService;
private final JwtUtil jwtUtil;
private boolean isKakaoToken = false;

@Autowired
public LoginMemberArgumentResolver(MemberService memberService, JwtUtil jwtUtil) {
this.memberService = memberService;
this.jwtUtil = jwtUtil;
}

@Override
public boolean supportsParameter(MethodParameter methodParameter) {
return methodParameter.getParameterType().equals(MemberDto.class);
}

@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) nativeWebRequest.getNativeRequest();

// 쿠키에서 JWT 토큰 추출
String token = null;
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("token".equals(cookie.getName())) {
token = cookie.getValue();
break;
}

if ("accessToken".equals(cookie.getName())) {
token = cookie.getValue();
isKakaoToken = true;
break;
}
}
}

if (token == null) {
throw new IllegalArgumentException("JWT token not found in cookies");
}

if (isKakaoToken) {
//카카오 토큰 검증
long kakaoId = KakaoTokenValidator.validateToken(token);
Optional<MemberDto> memberDto = memberService.findByKakaoId(kakaoId);

return memberDto.orElseThrow(() -> new IllegalArgumentException("User not found"));
}

Claims claims = jwtUtil.decodeToken(token); //decode
String memberEmail = claims.getSubject(); // subject를 email로 설정했기 때문에 userEmail로 사용
Optional<MemberDto> memberDto = memberService.findByEmail(memberEmail); //null이라면 인증된 것이 아닐 것이고 null이 아니라면 인증된 것
if (memberDto.isEmpty()) {
throw new IllegalArgumentException("User not found");
}
return memberDto;

}

}
11 changes: 11 additions & 0 deletions src/main/java/gift/Annotation/LoginMemberResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package gift.Annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginMemberResolver {
}
22 changes: 22 additions & 0 deletions src/main/java/gift/Config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package gift.Config;

import gift.Annotation.LoginMemberArgumentResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Autowired
private LoginMemberArgumentResolver loginMemberResolverHandlerMethodArgumentResolver;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginMemberResolverHandlerMethodArgumentResolver);
}

}
29 changes: 29 additions & 0 deletions src/main/java/gift/Controller/CategoryController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package gift.Controller;

import gift.Model.CategoryDto;
import gift.Service.CategoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

@Controller
@Tag(name = "Category", description = "카테고리 관련 api")
public class CategoryController {

private final CategoryService categoryService;

public CategoryController(CategoryService categoryService) {
this.categoryService = categoryService;
}

@Operation(summary = "모든 카테고리 조회", description = "모든 카테고리를 조회합니다.")
@GetMapping("/api/categories")
public ResponseEntity<List<CategoryDto>> getAllCategories() {
List<CategoryDto> categoryDtoList = categoryService.getAllCategories();
return ResponseEntity.ok(categoryDtoList);
}
}
45 changes: 45 additions & 0 deletions src/main/java/gift/Controller/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package gift.Controller;

import gift.Exception.ForbiddenException;
import gift.Exception.UnauthorizedException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.ui.Model;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public String handleMethodArgumentNotValid(MethodArgumentNotValidException ex, Model model, HttpServletRequest request) {
FieldError fieldError = ex.getBindingResult().getFieldError();
String errorMessage = fieldError.getDefaultMessage();

model.addAttribute("error", errorMessage);
model.addAttribute("product", ex.getBindingResult().getTarget());

// 에러가 발생한 URL에 따라 다시 해당 페이지로 돌아가기
String requestUrl = request.getRequestURI();
if (requestUrl.equals("/api/products/create") || requestUrl.startsWith("/api/products/update")) {
return "product_form"; // product_form 뷰로 포워딩
}

return "redirect:/api/products";
}

@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<String> handleUnauthorizedException(UnauthorizedException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.UNAUTHORIZED);
}

@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<String> handleForbiddenException(ForbiddenException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.FORBIDDEN);
}

}
115 changes: 115 additions & 0 deletions src/main/java/gift/Controller/KakaoOAuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package gift.Controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import gift.Model.KakaoAccessTokenDto;
import gift.Model.KakaoMemberDto;
import gift.Model.MemberDto;
import gift.Service.MemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;

@RestController
@Tag(name = "Kakao Login", description = "카카오 로그인 관련 api")
public class KakaoOAuthController {

@Value("${kakao.clientId}")
private String clientId;

@Autowired
private MemberService memberService;

@GetMapping("/oauth/authorize")
@Operation(summary = "카카오 로그인 화면", description = "카카오 로그인 화면을 보여줍니다.")
public void authorize(HttpServletResponse response) throws IOException {
var url = "https://kauth.kakao.com/oauth/authorize";
var redirectUri = "http://localhost:8080/auth/kakao/callback";
var responseType = "code";

String redirectUrl = url + "?response_type=" + responseType + "&client_id=" + clientId + "&redirect_uri=" + redirectUri;
response.sendRedirect(redirectUrl);
}


@GetMapping("/auth/kakao/callback")
@Operation(summary = "카카오 로그인 수행", description = "인가 코드를 통해 로그인을 수행합니다.")
public ResponseEntity<?> callBack(@RequestParam("code") String code, HttpServletResponse response) throws Exception {
//인가 코드로 토큰 받아오기
String accessToken = getAccessToken(code);

//토큰을 쿠키에 저장하기
Cookie cookie = new Cookie("accessToken", accessToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);

//토큰을 통해 사용자 정보 받아오기
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

HttpEntity<String> entity = new HttpEntity<>(null, headers);
ResponseEntity<String> responseEntity = restTemplate.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.GET,
entity,
String.class
);

ObjectMapper objectMapper = new ObjectMapper();
//id는 가능한데 email을 가져오려고 하면 null이 되는 이유??????????
KakaoMemberDto kakaoMemberDto = objectMapper.readValue(responseEntity.getBody(), KakaoMemberDto.class);
long id = kakaoMemberDto.getId();

//이미 가입된 회원인지 확인, 가입되지 않은 회원이라면 회원가입 진행
if (memberService.findByKakaoId(id).isEmpty()) {
MemberDto memberDto = new MemberDto();
memberDto.setKakaoId(id);
memberService.register(memberDto);
}

//로그인 처리
headers = new HttpHeaders();
headers.add("Location", "/products");

return new ResponseEntity<>("Successfully logged in", headers, HttpStatus.SEE_OTHER);

}

@Operation(summary = "토큰 발급", description = "토큰을 발급받습니다.")
public String getAccessToken(String code) throws Exception {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", "http://localhost:8080/auth/kakao/callback");
body.add("code", code);

HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, headers); //순서가 body가 먼저

ResponseEntity<String> response = restTemplate.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
requestEntity,
String.class
); //Http 요청 보내고 받기

ObjectMapper objectMapper = new ObjectMapper();
KakaoAccessTokenDto kakaoAccessTokenDto = objectMapper.readValue(response.getBody(), KakaoAccessTokenDto.class);
return kakaoAccessTokenDto.getAccess_token();
}
}
Loading

0 comments on commit a6acbfa

Please sign in to comment.