From 3f93b5e17632d0b8609166a7793e0dd7d18b6b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B8=EC=A4=80?= <74056843+sejoon00@users.noreply.github.com> Date: Thu, 13 Feb 2025 19:05:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[refactor/#48]=20jwt=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=ED=99=98=20&=20swagger=20ui=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 46 +++++++- .../auth/controller/AuthControllerDocs.java | 81 +++++++++++++ .../auth/dto/request/AdminLoginRequest.java | 4 + .../dto/response/AccessTokenResponse.java | 5 + .../domain/auth/service/AuthService.java | 65 +++++++++++ .../controller/ConceptTagController.java | 2 + .../dto/response/ConceptTagResponse.java | 3 + .../member/controller/MemberController.java | 2 + .../controller/PracticeTestTagController.java | 3 + .../problem/controller/ProblemController.java | 2 + .../controller/ProblemSearchController.java | 2 + .../domain/problem/ProblemAdminId.java | 2 +- .../dto/request/ProblemPostRequest.java | 2 +- .../dto/request/ProblemUpdateRequest.java | 2 + .../dto/response/ChildProblemGetResponse.java | 2 + .../response/ConceptTagSearchResponse.java | 5 +- .../dto/response/PracticeTestTagResponse.java | 3 + .../dto/response/ProblemGetResponse.java | 2 + .../response/ProblemSearchGetResponse.java | 8 +- .../controller/ProblemSetController.java | 2 + .../ProblemSetSearchController.java | 9 +- .../publish/controller/PublishController.java | 2 + .../config/security/SecurityConfig.java | 3 +- .../global/error/GlobalExceptionHandler.java | 9 ++ .../handler/EmailPasswordSuccessHandler.java | 6 +- .../global/security/utils/CookieUtil.java | 20 ++++ .../global/security/utils/JwtUtil.java | 9 ++ .../auth/controller/AuthControllerTest.java | 7 +- .../controller/RefreshTokenReissueTest.java | 108 ++++++++++++++++++ 29 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java create mode 100644 src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java create mode 100644 src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java create mode 100644 src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java create mode 100644 src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java index 353a056..862b52a 100644 --- a/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java +++ b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthController.java @@ -1,10 +1,20 @@ package com.moplus.moplus_server.domain.auth.controller; import com.moplus.moplus_server.domain.auth.dto.request.AdminLoginRequest; +import com.moplus.moplus_server.domain.auth.dto.response.AccessTokenResponse; import com.moplus.moplus_server.domain.auth.dto.response.TokenResponse; -import io.swagger.v3.oas.annotations.Operation; +import com.moplus.moplus_server.domain.auth.service.AuthService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.NotFoundException; +import com.moplus.moplus_server.global.security.utils.CookieUtil; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -13,15 +23,41 @@ @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthControllerDocs { - @Operation(summary = "어드민 로그인", description = "아아디 패스워드 로그인 후 토큰 발급합니다.") + private final AuthService authService; + private final CookieUtil cookieUtil; + + private static String validateRefreshTokenCookie(HttpServletRequest request) { + if (request.getCookies() == null) { + throw new NotFoundException(ErrorCode.BLANK_INPUT_VALUE); + } + Cookie[] cookies = request.getCookies(); + return Arrays.stream(cookies) + .filter(cookie -> "refreshToken".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElseThrow(() -> new NotFoundException(ErrorCode.BLANK_INPUT_VALUE)); + } + + @Override @PostMapping("/admin/login") - public ResponseEntity adminLogin( - @RequestBody AdminLoginRequest request + public ResponseEntity adminLogin( + @Valid @RequestBody AdminLoginRequest request ) { // 실제 처리는 Security 필터에서 이루어지며, 이 메서드는 Swagger 명세용입니다. return null; } + @Override + @GetMapping("/reissue") + public ResponseEntity reissueToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = validateRefreshTokenCookie(request); + + TokenResponse tokenResponse = authService.reissueToken(refreshToken); + + response.addCookie(cookieUtil.createCookie(tokenResponse.refreshToken())); + + return ResponseEntity.ok(new AccessTokenResponse(tokenResponse.accessToken())); + } } diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java new file mode 100644 index 0000000..90a2610 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerDocs.java @@ -0,0 +1,81 @@ +package com.moplus.moplus_server.domain.auth.controller; + +import com.moplus.moplus_server.domain.auth.dto.request.AdminLoginRequest; +import com.moplus.moplus_server.domain.auth.dto.response.AccessTokenResponse; +import com.moplus.moplus_server.global.error.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; + +@Tag(name = "인증", description = "인증 관련 API") +public interface AuthControllerDocs { + + @Operation( + summary = "어드민 로그인", + description = "이메일과 비밀번호로 로그인하여 액세스 토큰을 발급받고 리프레시 토큰을 쿠키에 설정합니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AccessTokenResponse.class) + ), + headers = @Header( + name = "Set-Cookie", + description = "리프레시 토큰이 담긴 HTTP Only 쿠키", + schema = @Schema( + type = "string", + example = "refreshToken=xxx; Path=/; HttpOnly; Secure; SameSite=None" + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (잘못된 이메일 또는 비밀번호)", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + ResponseEntity adminLogin(AdminLoginRequest request); + + @Operation( + summary = "토큰 재발급", + description = "리프레시 토큰을 통해 새로운 액세스 토큰을 발급하고 새로운 리프레시 토큰을 쿠키에 설정합니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AccessTokenResponse.class) + ), + headers = @Header( + name = "Set-Cookie", + description = "새로운 리프레시 토큰이 담긴 HTTP Only 쿠키", + schema = @Schema( + type = "string", + example = "refreshToken=xxx; Path=/; HttpOnly; Secure; SameSite=None" + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 리프레시 토큰", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "리프레시 토큰 쿠키 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + } + ) + ResponseEntity reissueToken(HttpServletRequest request, HttpServletResponse response); +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java b/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java index 861df83..e0c6c14 100644 --- a/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/auth/dto/request/AdminLoginRequest.java @@ -1,7 +1,11 @@ package com.moplus.moplus_server.domain.auth.dto.request; +import jakarta.validation.constraints.NotNull; + public record AdminLoginRequest( + @NotNull(message = "이메일을 입력해주세요.") String email, + @NotNull(message = "비밀번호를 입력해주세요.") String password ) { } diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java new file mode 100644 index 0000000..f8d70c2 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/dto/response/AccessTokenResponse.java @@ -0,0 +1,5 @@ +package com.moplus.moplus_server.domain.auth.dto.response; + +public record AccessTokenResponse( + String accessToken +) {} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java b/src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java new file mode 100644 index 0000000..b11bac9 --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/domain/auth/service/AuthService.java @@ -0,0 +1,65 @@ +package com.moplus.moplus_server.domain.auth.service; + +import com.moplus.moplus_server.domain.auth.dto.response.TokenResponse; +import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.domain.member.service.MemberService; +import com.moplus.moplus_server.global.error.exception.ErrorCode; +import com.moplus.moplus_server.global.error.exception.InvalidValueException; +import com.moplus.moplus_server.global.security.exception.JwtInvalidException; +import com.moplus.moplus_server.global.security.utils.JwtUtil; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtUtil jwtUtil; + private final MemberService memberService; + + @Transactional + public TokenResponse reissueToken(String refreshToken) { + if (refreshToken == null) { + throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE); + } + + Claims claims = getClaims(refreshToken); + final Member member = getMemberById(claims.getSubject()); + + // 새로운 액세스 토큰과 리프레시 토큰 생성 + String newAccessToken = jwtUtil.generateAccessToken(member); + String newRefreshToken = jwtUtil.generateRefreshToken(member); + + return new TokenResponse(newAccessToken, newRefreshToken); + } + + private Claims getClaims(String refreshToken) { + Claims claims; + try { + claims = jwtUtil.getRefreshTokenClaims(refreshToken); + } catch (ExpiredJwtException expiredJwtException) { + throw new JwtInvalidException(ErrorCode.EXPIRED_TOKEN.getMessage()); + } catch (SignatureException signatureException) { + throw new JwtInvalidException(ErrorCode.WRONG_TYPE_TOKEN.getMessage()); + } catch (MalformedJwtException malformedJwtException) { + throw new JwtInvalidException(ErrorCode.UNSUPPORTED_TOKEN.getMessage()); + } catch (IllegalArgumentException illegalArgumentException) { + throw new JwtInvalidException(ErrorCode.UNKNOWN_ERROR.getMessage()); + } + return claims; + } + + private Member getMemberById(String id) { + try { + return memberService.getMemberById(Long.parseLong(id)); + } catch (Exception e) { + throw new BadCredentialsException(ErrorCode.BAD_CREDENTIALS.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java b/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java index 094e6ae..e575025 100644 --- a/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java +++ b/src/main/java/com/moplus/moplus_server/domain/concept/controller/ConceptTagController.java @@ -3,6 +3,7 @@ import com.moplus.moplus_server.domain.concept.dto.response.ConceptTagResponse; import com.moplus.moplus_server.domain.concept.repository.ConceptTagRepository; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "문항", description = "문항 관련 API") @RestController @RequestMapping("/api/v1/conceptTags") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java b/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java index e92b490..0f5e5cd 100644 --- a/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/concept/dto/response/ConceptTagResponse.java @@ -1,9 +1,12 @@ package com.moplus.moplus_server.domain.concept.dto.response; import com.moplus.moplus_server.domain.concept.domain.ConceptTag; +import jakarta.validation.constraints.NotNull; public record ConceptTagResponse( + @NotNull(message = "개념 태그 ID는 필수입니다") Long id, + @NotNull(message = "개념 태그 이름은 필수입니다") String name ) { public static ConceptTagResponse of(ConceptTag entity) { diff --git a/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java b/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java index 6fd8166..43a0cb2 100644 --- a/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java +++ b/src/main/java/com/moplus/moplus_server/domain/member/controller/MemberController.java @@ -4,12 +4,14 @@ import com.moplus.moplus_server.domain.member.dto.response.MemberGetResponse; import com.moplus.moplus_server.global.annotation.AuthUser; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "회원", description = "회원 관련 API") @RestController @RequestMapping("/api/v1/member") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java index 1534257..ba0703c 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/PracticeTestTagController.java @@ -3,6 +3,7 @@ import com.moplus.moplus_server.domain.problem.dto.response.PracticeTestTagResponse; import com.moplus.moplus_server.domain.problem.repository.PracticeTestTagRepository; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -10,6 +11,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "문항", description = "문항 관련 API") @RestController @RequestMapping("/api/v1/practiceTestTags") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java index 4d7f2fb..d4acee7 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemController.java @@ -8,6 +8,7 @@ import com.moplus.moplus_server.domain.problem.service.ProblemSaveService; import com.moplus.moplus_server.domain.problem.service.ProblemUpdateService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "문항", description = "문항 관련 API") @RestController @RequestMapping("/api/v1/problems") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java index 7c8c50d..7ec3490 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/controller/ProblemSearchController.java @@ -3,6 +3,7 @@ import com.moplus.moplus_server.domain.problem.dto.response.ProblemSearchGetResponse; import com.moplus.moplus_server.domain.problem.repository.ProblemSearchRepositoryCustom; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "문항", description = "문항 관련 API") @RestController @RequestMapping("/api/v1/problems") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminId.java b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminId.java index a30bb59..7c04c6e 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminId.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/domain/problem/ProblemAdminId.java @@ -12,7 +12,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProblemAdminId implements Serializable { - @Column(name = "problem_admin_id") + @Column(name = "problem_admin_id", nullable = false) private String id; public ProblemAdminId(String id) { diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java index c909206..0933dec 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemPostRequest.java @@ -7,7 +7,7 @@ import jakarta.validation.constraints.NotNull; public record ProblemPostRequest( - @NotNull(message = "문제 유형은 필수입니다") + @NotNull(message = "문항 유형은 필수입니다") ProblemType problemType, Long practiceTestId, int number diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java index 1dcab23..c55fb83 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/request/ProblemUpdateRequest.java @@ -2,10 +2,12 @@ import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Set; public record ProblemUpdateRequest( + @NotNull(message = "문제 유형은 필수입니다") ProblemType problemType, Long practiceTestId, int number, diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java index ae7552a..3daed3d 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ChildProblemGetResponse.java @@ -2,11 +2,13 @@ import com.moplus.moplus_server.domain.problem.domain.childProblem.ChildProblem; import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; +import jakarta.validation.constraints.NotNull; import java.util.Set; import lombok.Builder; @Builder public record ChildProblemGetResponse( + @NotNull(message = "새끼 문항 ID는 필수입니다") Long childProblemId, String imageUrl, AnswerType answerType, diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java index 75e5c73..1229df6 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ConceptTagSearchResponse.java @@ -1,13 +1,16 @@ package com.moplus.moplus_server.domain.problem.dto.response; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor public class ConceptTagSearchResponse { + @NotNull(message = "개념 태그 ID는 필수입니다") private Long id; - private String name; // 예시로 태그 이름을 추가 (필요에 따라 변경 가능) + @NotNull(message = "개념 태그 이름은 필수입니다") + private String name; public ConceptTagSearchResponse(Long id, String name) { this.id = id; diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java index a4b0f1c..3f2a344 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/PracticeTestTagResponse.java @@ -1,9 +1,12 @@ package com.moplus.moplus_server.domain.problem.dto.response; import com.moplus.moplus_server.domain.problem.domain.practiceTest.PracticeTestTag; +import jakarta.validation.constraints.NotNull; public record PracticeTestTagResponse( + @NotNull(message = "기출 모의고사 태그 ID는 필수입니다") Long id, + @NotNull(message = "기출 모의고사 태그 이름은 필수입니다") String name ) { public static PracticeTestTagResponse of(PracticeTestTag practiceTestTag) { diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java index a39c30f..650189b 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemGetResponse.java @@ -3,12 +3,14 @@ import com.moplus.moplus_server.domain.problem.domain.problem.AnswerType; import com.moplus.moplus_server.domain.problem.domain.problem.Problem; import com.moplus.moplus_server.domain.problem.domain.problem.ProblemType; +import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Set; import lombok.Builder; @Builder public record ProblemGetResponse( + @NotNull(message = "문항 ID은 필수입니다") String problemId, Set conceptTagIds, Long practiceTestId, diff --git a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java index 7a1710b..459b45e 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java +++ b/src/main/java/com/moplus/moplus_server/domain/problem/dto/response/ProblemSearchGetResponse.java @@ -1,5 +1,6 @@ package com.moplus.moplus_server.domain.problem.dto.response; +import jakarta.validation.constraints.NotNull; import java.util.Set; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,15 +8,16 @@ @Getter @NoArgsConstructor public class ProblemSearchGetResponse { + @NotNull(message = "문항 ID는 필수입니다") private String problemId; - private String comment; + private String memo; private String mainProblemImageUrl; private Set conceptTagResponses; - public ProblemSearchGetResponse(String problemId, String comment, String mainProblemImageUrl, + public ProblemSearchGetResponse(String problemId, String memo, String mainProblemImageUrl, Set conceptTagResponses) { this.problemId = problemId; - this.comment = comment; + this.memo = memo; this.mainProblemImageUrl = mainProblemImageUrl; this.conceptTagResponses = conceptTagResponses; } diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java index 8b13e35..f38a715 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetController.java @@ -11,6 +11,7 @@ import com.moplus.moplus_server.domain.problemset.service.ProblemSetUpdateService; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -22,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "문항세트", description = "문항세트 관련 API") @RestController @RequestMapping("/api/v1/problemSet") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java index 09c0b11..044c567 100644 --- a/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java +++ b/src/main/java/com/moplus/moplus_server/domain/problemset/controller/ProblemSetSearchController.java @@ -4,6 +4,7 @@ import com.moplus.moplus_server.domain.problemset.dto.response.ProblemSetSearchGetResponse; import com.moplus.moplus_server.domain.problemset.repository.ProblemSetSearchRepositoryCustom; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -19,6 +20,7 @@ public class ProblemSetSearchController { private final ProblemSetSearchRepositoryCustom problemSetSearchRepository; + @Tag(name = "문항세트", description = "문항세트 관련 API") @GetMapping("/search") @Operation( summary = "문항세트 검색", @@ -29,10 +31,12 @@ public ResponseEntity> search( @RequestParam(value = "problemTitle", required = false) String problemTitle, @RequestParam(value = "conceptTagNames", required = false) List conceptTagNames ) { - List problemSets = problemSetSearchRepository.search(problemSetTitle, problemTitle, conceptTagNames); + List problemSets = problemSetSearchRepository.search(problemSetTitle, problemTitle, + conceptTagNames); return ResponseEntity.ok(problemSets); } + @Tag(name = "발행", description = "발행 관련 API") @GetMapping("/confirm/search") @Operation( summary = "발행용 문항세트 검색", @@ -43,7 +47,8 @@ public ResponseEntity> confirmSearch( @RequestParam(value = "problemTitle", required = false) String problemTitle, @RequestParam(value = "conceptTagNames", required = false) List conceptTagNames ) { - List problemSets = problemSetSearchRepository.confirmSearch(problemSetTitle, problemTitle, conceptTagNames); + List problemSets = problemSetSearchRepository.confirmSearch(problemSetTitle, + problemTitle, conceptTagNames); return ResponseEntity.ok(problemSets); } } diff --git a/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java b/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java index a703911..924344e 100644 --- a/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java +++ b/src/main/java/com/moplus/moplus_server/domain/publish/controller/PublishController.java @@ -6,6 +6,7 @@ import com.moplus.moplus_server.domain.publish.service.PublishGetService; import com.moplus.moplus_server.domain.publish.service.PublishSaveService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "발행", description = "발행 관련 API") @RestController @RequestMapping("/api/v1/publish") @RequiredArgsConstructor diff --git a/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java b/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java index 4582202..62928cd 100644 --- a/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java +++ b/src/main/java/com/moplus/moplus_server/global/config/security/SecurityConfig.java @@ -36,7 +36,8 @@ public class SecurityConfig { private final EmailPasswordSuccessHandler emailPasswordSuccessHandler; private final JwtUtil jwtUtil; - private String[] allowUrls = {"/", "/favicon.ico", "/swagger-ui/**", "/v3/**", "/actuator/**"}; + private String[] allowUrls = {"/", "/favicon.ico", "/swagger-ui/**", "/v3/**", "/actuator/**", + "/api/v1/auth/reissue"}; @Value("${cors-allowed-origins}") private List corsAllowedOrigins; diff --git a/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java b/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java index 9e163aa..2e97cd6 100644 --- a/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/moplus/moplus_server/global/error/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.moplus.moplus_server.global.error.exception.BusinessException; import com.moplus.moplus_server.global.error.exception.ErrorCode; import com.moplus.moplus_server.global.error.exception.NotFoundException; +import com.moplus.moplus_server.global.security.exception.JwtInvalidException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.context.support.DefaultMessageSourceResolvable; @@ -69,4 +70,12 @@ protected ResponseEntity handleException(final Exception exceptio final ErrorResponse response = ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR); return new ResponseEntity<>(response, response.getStatus()); } + + @ExceptionHandler(JwtInvalidException.class) + protected ResponseEntity handleJwtInvalidException(final JwtInvalidException exception) { + log.error("handleJwtInvalidException", exception); + final ErrorResponse response = ErrorResponse.from(ErrorCode.BAD_CREDENTIALS); + + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); + } } diff --git a/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java b/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java index f5ebad6..67db246 100644 --- a/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java +++ b/src/main/java/com/moplus/moplus_server/global/security/handler/EmailPasswordSuccessHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.moplus.moplus_server.domain.member.domain.Member; +import com.moplus.moplus_server.global.security.utils.CookieUtil; import com.moplus.moplus_server.global.security.utils.JwtUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -18,6 +19,7 @@ public class EmailPasswordSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환을 위한 ObjectMapper @Override @@ -27,10 +29,10 @@ public void onAuthenticationSuccess(final HttpServletRequest request, final Http String accessToken = jwtUtil.generateAccessToken(member); String refreshToken = jwtUtil.generateRefreshToken(member); - // JSON 응답 생성 + response.addCookie(cookieUtil.createCookie(refreshToken)); + Map tokenResponse = new HashMap<>(); tokenResponse.put("accessToken", accessToken); - tokenResponse.put("refreshToken", refreshToken); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java b/src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java new file mode 100644 index 0000000..aa7305e --- /dev/null +++ b/src/main/java/com/moplus/moplus_server/global/security/utils/CookieUtil.java @@ -0,0 +1,20 @@ +package com.moplus.moplus_server.global.security.utils; + +import jakarta.servlet.http.Cookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public Cookie createCookie(String refreshToken) { + String cookieName = "refreshToken"; + Cookie cookie = new Cookie(cookieName, refreshToken); + + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(60 * 60 * 24 * 7); //일주일 + cookie.setAttribute("SameSite", "None"); + return cookie; + } +} diff --git a/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java b/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java index d282029..cc80237 100644 --- a/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java +++ b/src/main/java/com/moplus/moplus_server/global/security/utils/JwtUtil.java @@ -58,6 +58,15 @@ public Claims getAccessTokenClaims(Authentication authentication) { .getBody(); } + public Claims getRefreshTokenClaims(String refreshToken) { + return Jwts.parserBuilder() + .requireIssuer(jwtProperties.issuer()) + .setSigningKey(getRefreshTokenKey()) + .build() + .parseClaimsJws(refreshToken) + .getBody(); + } + private Key getAccessTokenKey() { return Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); } diff --git a/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java index 4d87cb3..d8f3c5e 100644 --- a/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/moplus/moplus_server/domain/auth/controller/AuthControllerTest.java @@ -1,6 +1,7 @@ package com.moplus.moplus_server.domain.auth.controller; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -58,7 +59,11 @@ class 어드민_로그인 { .content(requestBody)) .andExpect(status().isOk()) // HTTP 200 응답 확인 .andExpect(jsonPath("$.accessToken").isNotEmpty()) // accessToken 필드 존재 여부 확인 - .andExpect(jsonPath("$.refreshToken").isNotEmpty()); // refreshToken 필드 존재 여부 확인 + .andExpect(cookie().exists("refreshToken")) // 리프레시 토큰 쿠키 존재 확인 + .andExpect(cookie().httpOnly("refreshToken", true)) // HTTP Only 설정 확인 + .andExpect(cookie().secure("refreshToken", true)) // Secure 설정 확인 + .andExpect(cookie().path("refreshToken", "/")) // 쿠키 경로 확인 + .andExpect(cookie().attribute("refreshToken", "SameSite", "None")); } diff --git a/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java b/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java new file mode 100644 index 0000000..0bbecfb --- /dev/null +++ b/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java @@ -0,0 +1,108 @@ +package com.moplus.moplus_server.domain.auth.controller; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.moplus.moplus_server.global.properties.jwt.JwtProperties; +import com.moplus.moplus_server.global.security.utils.CookieUtil; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import java.security.Key; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("h2test") +@Sql({"/auth-test-data.sql"}) +public class RefreshTokenReissueTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private CookieUtil cookieUtil; + + private String validRefreshToken; + + @BeforeEach + public void setup() { + + // Generate a test token + Key key = Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + Date issuedAt = new Date(); // 3 hour ago + Date expiredAt = new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + validRefreshToken = Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject("1") + .claim("role", "ROLE_USER") + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(key) + .compact(); + } + + @Nested + class 토큰_재발급 { + + @Test + void 성공() throws Exception { + // given + Cookie refreshTokenCookie = cookieUtil.createCookie(validRefreshToken); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue") + .cookie(refreshTokenCookie)) + .andExpect(status().isOk()) + .andExpect(cookie().exists("refreshToken")) + .andExpect(cookie().httpOnly("refreshToken", true)) + .andExpect(cookie().secure("refreshToken", true)) + .andExpect(cookie().path("refreshToken", "/")) + .andExpect(cookie().attribute("refreshToken", "SameSite", "None")); + } + + @Test + void 실패_리프레시토큰_없음() throws Exception { + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue")) + .andExpect(status().isUnauthorized()); + } + + @Test + void 실패_유효하지_않은_리프레시토큰() throws Exception { + // given + Cookie invalidRefreshTokenCookie = new Cookie("refreshToken", "invalid_refresh_token"); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue") + .cookie(invalidRefreshTokenCookie)) + .andExpect(status().isUnauthorized()); + } + + @Test + void 실패_만료된_리프레시토큰() throws Exception { + // given + Cookie expiredRefreshTokenCookie = new Cookie("refreshToken", "expired_refresh_token"); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue") + .cookie(expiredRefreshTokenCookie)) + .andExpect(status().isUnauthorized()); + } + } +} From 59a7a07a4f95b03eef3a4ec11e17c988bff26978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B8=EC=A4=80?= <74056843+sejoon00@users.noreply.github.com> Date: Thu, 13 Feb 2025 19:07:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[refactor/#48]=20refresh=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EC=97=86=EC=9D=84=20=EB=95=8C=20400=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/problem/service/mapper/ProblemMapperImpl.java | 2 +- .../domain/auth/controller/RefreshTokenReissueTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java b/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java index cf20a4a..dbb174c 100644 --- a/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java +++ b/src/main/generated/com/moplus/moplus_server/domain/problem/service/mapper/ProblemMapperImpl.java @@ -14,7 +14,7 @@ @Generated( value = "org.mapstruct.ap.MappingProcessor", - date = "2025-02-13T05:08:28+0900", + date = "2025-02-13T19:05:39+0900", comments = "version: 1.6.3, compiler: javac, environment: Java 17.0.10 (JetBrains s.r.o.)" ) @Component diff --git a/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java b/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java index 0bbecfb..6349475 100644 --- a/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java +++ b/src/test/java/com/moplus/moplus_server/domain/auth/controller/RefreshTokenReissueTest.java @@ -80,7 +80,7 @@ class 토큰_재발급 { void 실패_리프레시토큰_없음() throws Exception { // when & then mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/auth/reissue")) - .andExpect(status().isUnauthorized()); + .andExpect(status().is4xxClientError()); } @Test