From 173f79593e581efa1fcc4488ede839ef9f3af9c0 Mon Sep 17 00:00:00 2001 From: yeonchaepark Date: Fri, 30 Jan 2026 17:55:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(#106):=20ocr=20=EB=AA=A8=EC=9D=98?= =?UTF-8?q?=EA=B3=A0=EC=82=AC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20pdf=20=EB=B0=B0=EC=B9=98=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/ocr/service/AsyncOcrService.java | 86 +++++++++++++++---- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/quizly/quizly/external/ocr/service/AsyncOcrService.java b/src/main/java/org/quizly/quizly/external/ocr/service/AsyncOcrService.java index bd84a9a..7634265 100644 --- a/src/main/java/org/quizly/quizly/external/ocr/service/AsyncOcrService.java +++ b/src/main/java/org/quizly/quizly/external/ocr/service/AsyncOcrService.java @@ -23,7 +23,6 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; -import java.util.stream.Collectors; @Log4j2 @Component @@ -60,27 +59,73 @@ public OcrExtractResponse execute(OcrExtractRequest request) { private CompletableFuture extractMergedPlainTextAsync(MultipartFile file) { - List batches = - PdfBoxPageBatchExtractor.splitToPdfBatches(file); + String contentType = file.getContentType(); + String extension = getFileExtension(file); - List> futures = - batches.stream() + boolean isPdf = + contentType != null + && "application/pdf".equalsIgnoreCase(contentType) + && "pdf".equals(extension); + + boolean isImage = + contentType != null + && contentType.startsWith("image/") + && List.of("jpg", "jpeg", "png", "bmp", "webp").contains(extension); + + if (isPdf) { + try { + List batches = + PdfBoxPageBatchExtractor.splitToPdfBatches(file); + + List> futures = + batches.stream() .map(this::callAsync) .toList(); - CompletableFuture allDone = - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + return CompletableFuture + .allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List results = futures.stream() + .map(CompletableFuture::join) + .toList(); + + boolean hasFailure = results.stream() + .anyMatch(response -> !response.isSuccess()); + + if (hasFailure) { + throw OcrExtractErrorCode.OCR_PROCESS_FAILED.toException(); + } + + List plainTexts = results.stream() + .map(ClovaOcrService.ClovaOcrResponse::getPlainText) + .toList(); + + return String.join("\n", plainTexts); + }); + + } catch (Exception e) { + log.error("[AsyncOcrService] PDF split failed", e); - return allDone.thenApply(v -> - futures.stream() - .map(CompletableFuture::join) - .filter(ClovaOcrService.ClovaOcrResponse::isSuccess) - .map(ClovaOcrService.ClovaOcrResponse::getPlainText) - .collect(Collectors.joining("\n")) + return CompletableFuture.failedFuture( + OcrExtractErrorCode.PDF_SPLIT_FAILED.toException() + ); + } + } + if (isImage) { + return callAsync(file) + .thenApply(response -> { + if (!response.isSuccess()) { + throw OcrExtractErrorCode.OCR_PROCESS_FAILED.toException(); + } + return response.getPlainText(); + }); + } + + return CompletableFuture.failedFuture( + OcrExtractErrorCode.UNSUPPORTED_FILE_TYPE.toException() ); } - public CompletableFuture callAsync(MultipartFile batch) { ClovaOcrService.ClovaOcrRequest request = ClovaOcrService.ClovaOcrRequest.builder() @@ -92,6 +137,14 @@ public CompletableFuture callAsync(MultipartFi ocrExecutor ); } + private String getFileExtension(MultipartFile file) { + String filename = file.getOriginalFilename(); + if (filename == null || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + } + @Getter @Builder @@ -119,7 +172,10 @@ public static class OcrExtractResponse extends BaseResponse @Getter @RequiredArgsConstructor public enum OcrExtractErrorCode implements BaseErrorCode { - NOT_EXIST_FILE(HttpStatus.BAD_REQUEST, "요청 파일이 존재하지 않습니다."); + NOT_EXIST_FILE(HttpStatus.BAD_REQUEST, "요청 파일이 존재하지 않습니다."), + UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + PDF_SPLIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PDF 페이지 분할에 실패했습니다."), + OCR_PROCESS_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OCR 처리에 실패했습니다."); private final HttpStatus httpStatus; private final String message; From 9083023dae5fad83bdeb1c1001ea1739f009d4a7 Mon Sep 17 00:00:00 2001 From: fnzl54 Date: Mon, 2 Feb 2026 00:07:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(#108):=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RefreshTokenRepository.java | 2 + .../delete/RevokeRefreshTokenController.java | 65 ++++++++++++++ .../service/RevokeRefreshTokenService.java | 86 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java create mode 100644 src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java diff --git a/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java b/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java index 4d7daaf..d9026f1 100644 --- a/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java +++ b/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java @@ -7,4 +7,6 @@ public interface RefreshTokenRepository extends JpaRepository { Optional findByProviderId(String providerId); + + void deleteByProviderId(String providerId); } diff --git a/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java b/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java new file mode 100644 index 0000000..4bfecb2 --- /dev/null +++ b/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java @@ -0,0 +1,65 @@ +package org.quizly.quizly.oauth.controller.delete; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.quizly.quizly.configuration.swagger.ApiErrorCode; +import org.quizly.quizly.core.application.BaseResponse; +import org.quizly.quizly.core.exception.error.GlobalErrorCode; +import org.quizly.quizly.oauth.UserPrincipal; +import org.quizly.quizly.oauth.service.RevokeRefreshTokenService; +import org.quizly.quizly.oauth.service.RevokeRefreshTokenService.RevokeRefreshTokenErrorCode; +import org.quizly.quizly.oauth.service.RevokeRefreshTokenService.RevokeRefreshTokenRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Tag(name = "Auth", description = "인증") +public class RevokeRefreshTokenController { + + private final RevokeRefreshTokenService revokeRefreshTokenService; + + @Operation( + summary = "리프레시 토큰 무효화 API (로그아웃)", + description = "인증된 사용자의 리프레시 토큰을 무효화하고 쿠키를 삭제합니다.", + operationId = "/auth/logout" + ) + @DeleteMapping("/auth/logout") + @ApiErrorCode(errorCodes = {GlobalErrorCode.class, RevokeRefreshTokenErrorCode.class}) + public ResponseEntity revokeRefreshToken( + @AuthenticationPrincipal UserPrincipal userPrincipal, + HttpServletResponse response + ) { + + var serviceResponse = revokeRefreshTokenService.execute( RevokeRefreshTokenRequest.builder() + .providerId(userPrincipal.getProviderId()) + .build()); + + if (!serviceResponse.isSuccess()) { + Optional.of(serviceResponse) + .map(BaseResponse::getErrorCode) + .ifPresent(errorCode -> { + throw errorCode.toException(); + }); + } + + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("Lax") + .maxAge(0) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java b/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java new file mode 100644 index 0000000..3f28948 --- /dev/null +++ b/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java @@ -0,0 +1,86 @@ +package org.quizly.quizly.oauth.service; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; +import org.quizly.quizly.core.application.BaseRequest; +import org.quizly.quizly.core.application.BaseResponse; +import org.quizly.quizly.core.application.BaseService; +import org.quizly.quizly.core.domin.repository.RefreshTokenRepository; +import org.quizly.quizly.core.exception.DomainException; +import org.quizly.quizly.core.exception.error.BaseErrorCode; +import org.quizly.quizly.oauth.service.RevokeRefreshTokenService.RevokeRefreshTokenRequest; +import org.quizly.quizly.oauth.service.RevokeRefreshTokenService.RevokeRefreshTokenResponse; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class RevokeRefreshTokenService implements BaseService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public RevokeRefreshTokenResponse execute(RevokeRefreshTokenRequest revokeRefreshTokenRequest) { + String providerId = revokeRefreshTokenRequest.getProviderId(); + + if (!revokeRefreshTokenRequest.isValid()) { + return RevokeRefreshTokenResponse.builder() + .success(false) + .errorCode(RevokeRefreshTokenErrorCode.PROVIDER_ID_MISSING) + .build(); + } + + refreshTokenRepository.deleteByProviderId(providerId); + + return RevokeRefreshTokenResponse.builder() + .success(true) + .build(); + } + + @Getter + @RequiredArgsConstructor + public enum RevokeRefreshTokenErrorCode implements BaseErrorCode { + PROVIDER_ID_MISSING(HttpStatus.BAD_REQUEST, "사용자 인증 정보가 제공되지 않았습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } + } + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class RevokeRefreshTokenRequest implements BaseRequest { + private String providerId; + + @Override + public boolean isValid() { + return providerId != null && !providerId.isEmpty(); + } + } + + @Getter + @Setter + @SuperBuilder + @NoArgsConstructor + @ToString + public static class RevokeRefreshTokenResponse extends BaseResponse { + } +} \ No newline at end of file From 8c8358d00fa3621df959461a25ae03a9c82dfa01 Mon Sep 17 00:00:00 2001 From: fnzl54 Date: Fri, 6 Feb 2026 13:31:32 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(#110):=20jwt=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20providerId=20=EA=B8=B0=EB=B0=98=EC=97=90=EC=84=9C=20userId?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=EC=9C=BC=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 --- .../service/CreateOnboardingService.java | 8 ++-- .../account/service/ReadUserService.java | 8 ++-- .../service/BatchAggregateSummaryService.java | 8 ++-- .../core/domin/entity/RefreshToken.java | 5 +-- .../repository/RefreshTokenRepository.java | 4 +- .../quizly/jwt/JwtAuthenticationFilter.java | 11 ++++-- .../org/quizly/quizly/jwt/JwtProvider.java | 17 ++++----- .../oauth/OAuth2LoginSuccessHandler.java | 38 ++++++++++++------- .../quizly/quizly/oauth/UserPrincipal.java | 22 ++--------- .../delete/RevokeRefreshTokenController.java | 6 ++- .../oauth/service/OAuth2LoginUserService.java | 4 +- .../service/RevokeRefreshTokenService.java | 12 +++--- 12 files changed, 72 insertions(+), 71 deletions(-) diff --git a/src/main/java/org/quizly/quizly/account/service/CreateOnboardingService.java b/src/main/java/org/quizly/quizly/account/service/CreateOnboardingService.java index 2fa268d..eec514c 100644 --- a/src/main/java/org/quizly/quizly/account/service/CreateOnboardingService.java +++ b/src/main/java/org/quizly/quizly/account/service/CreateOnboardingService.java @@ -37,17 +37,17 @@ public CreateOnboardingResponse execute(CreateOnboardingRequest request) { .build(); } - String providerId = request.getUserPrincipal().getProviderId(); - if (providerId == null || providerId.isBlank()) { + Long userId = request.getUserPrincipal().getUserId(); + if (userId == null) { return CreateOnboardingResponse.builder() .success(false) .errorCode(CreateOnboardingErrorCode.NOT_EXIST_PROVIDER_ID) .build(); } - Optional userOptional = userRepository.findByProviderId(providerId); + Optional userOptional = userRepository.findById(userId); if (userOptional.isEmpty()) { - log.error("[CreateOnboardingService] User not found for providerId: {}", providerId); + log.error("[CreateOnboardingService] User not found for userId: {}", userId); return CreateOnboardingResponse.builder() .success(false) .errorCode(CreateOnboardingErrorCode.NOT_FOUND_USER) diff --git a/src/main/java/org/quizly/quizly/account/service/ReadUserService.java b/src/main/java/org/quizly/quizly/account/service/ReadUserService.java index be1be42..201a8d3 100644 --- a/src/main/java/org/quizly/quizly/account/service/ReadUserService.java +++ b/src/main/java/org/quizly/quizly/account/service/ReadUserService.java @@ -34,17 +34,17 @@ public ReadUserResponse execute(ReadUserRequest request) { .build(); } - String providerId = request.getUserPrincipal().getProviderId(); - if (providerId == null || providerId.isBlank()) { + Long userId = request.getUserPrincipal().getUserId(); + if (userId == null) { return ReadUserResponse.builder() .success(false) .errorCode(ReadUserErrorCode.NOT_EXIST_PROVIDER_ID) .build(); } - Optional userOptional = userRepository.findByProviderId(providerId); + Optional userOptional = userRepository.findById(userId); if (userOptional.isEmpty()) { - log.error("[ReadUserService] User not found for providerId: {}", providerId); + log.error("[ReadUserService] User not found for userId: {}", userId); return ReadUserResponse.builder() .success(false) .errorCode(ReadUserErrorCode.NOT_FOUND_USER) diff --git a/src/main/java/org/quizly/quizly/admin/service/BatchAggregateSummaryService.java b/src/main/java/org/quizly/quizly/admin/service/BatchAggregateSummaryService.java index 9cab105..d90dcb1 100644 --- a/src/main/java/org/quizly/quizly/admin/service/BatchAggregateSummaryService.java +++ b/src/main/java/org/quizly/quizly/admin/service/BatchAggregateSummaryService.java @@ -41,17 +41,17 @@ public BatchAggregateSummaryResponse execute(BatchAggregateSummaryRequest reques .build(); } - String providerId = request.getUserPrincipal().getProviderId(); - if (providerId == null || providerId.isBlank()) { + Long userId = request.getUserPrincipal().getUserId(); + if (userId == null) { return BatchAggregateSummaryResponse.builder() .success(false) .errorCode(BatchAggregateSummaryErrorCode.INVALID_PARAMETER) .build(); } - var userOptional = userRepository.findByProviderId(providerId); + var userOptional = userRepository.findById(userId); if (userOptional.isEmpty()) { - log.error("[BatchAggregateSummaryService] User not found for providerId: {}", providerId); + log.error("[BatchAggregateSummaryService] User not found for userId: {}", userId); return BatchAggregateSummaryResponse.builder() .success(false) .errorCode(BatchAggregateSummaryErrorCode.NOT_FOUND_USER) diff --git a/src/main/java/org/quizly/quizly/core/domin/entity/RefreshToken.java b/src/main/java/org/quizly/quizly/core/domin/entity/RefreshToken.java index 1597a5c..af10ba5 100644 --- a/src/main/java/org/quizly/quizly/core/domin/entity/RefreshToken.java +++ b/src/main/java/org/quizly/quizly/core/domin/entity/RefreshToken.java @@ -20,10 +20,7 @@ public class RefreshToken extends BaseEntity { @Column(nullable = false, unique = true) - private String providerId; - - @Column(nullable = false) - private String name; + private Long userId; @Column(nullable = false) private String token; diff --git a/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java b/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java index d9026f1..514db13 100644 --- a/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java +++ b/src/main/java/org/quizly/quizly/core/domin/repository/RefreshTokenRepository.java @@ -6,7 +6,7 @@ public interface RefreshTokenRepository extends JpaRepository { - Optional findByProviderId(String providerId); + Optional findByUserId(Long userId); - void deleteByProviderId(String providerId); + void deleteByUserId(Long userId); } diff --git a/src/main/java/org/quizly/quizly/jwt/JwtAuthenticationFilter.java b/src/main/java/org/quizly/quizly/jwt/JwtAuthenticationFilter.java index d9112b1..ee7a0e7 100644 --- a/src/main/java/org/quizly/quizly/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/quizly/quizly/jwt/JwtAuthenticationFilter.java @@ -38,10 +38,13 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication(authentication); } else { request.setAttribute("exception", errorCode); - if (errorCode == AuthErrorCode.EXPIRED_ACCESS_TOKEN) { - log.warn("Expired JWT token: {}", token); - } else { - log.error("Invalid JWT token: {}", token); + if (errorCode != AuthErrorCode.EXPIRED_ACCESS_TOKEN) { + try { + Long userId = jwtProvider.getUserId(token); + log.warn("[Security] Invalid JWT token detected - userId: {}", userId); + } catch (Exception e) { + log.warn("[Security] Invalid JWT token detected - cannot parse userId"); + } } } } else { diff --git a/src/main/java/org/quizly/quizly/jwt/JwtProvider.java b/src/main/java/org/quizly/quizly/jwt/JwtProvider.java index 9fb5033..052937b 100644 --- a/src/main/java/org/quizly/quizly/jwt/JwtProvider.java +++ b/src/main/java/org/quizly/quizly/jwt/JwtProvider.java @@ -7,7 +7,6 @@ import java.nio.charset.StandardCharsets; import java.util.Date; import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; import lombok.RequiredArgsConstructor; import org.quizly.quizly.core.domin.entity.User.Role; import org.quizly.quizly.jwt.error.AuthErrorCode; @@ -37,9 +36,9 @@ private void init() { this.secretKey = io.jsonwebtoken.security.Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } - public String getProviderId(String token) { + public Long getUserId(String token) { return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload() - .get("providerId", String.class); + .get("userId", Long.class); } public String getRole(String token) { @@ -58,9 +57,9 @@ public AuthErrorCode validateToken(String token) { } } - public String generateAccessToken(String providerId, String role) { + public String generateAccessToken(Long userId, String role) { return Jwts.builder() - .claim("providerId", providerId) + .claim("userId", userId) .claim("role", role) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) @@ -68,9 +67,9 @@ public String generateAccessToken(String providerId, String role) { .compact(); } - public String generateRefreshToken(String providerId) { + public String generateRefreshToken(Long userId) { return Jwts.builder() - .claim("providerId", providerId) + .claim("userId", userId) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration)) .signWith(secretKey) @@ -78,11 +77,11 @@ public String generateRefreshToken(String providerId) { } public Authentication getAuthentication(String token) { - String providerId = getProviderId(token); + Long userId = getUserId(token); String roleString = getRole(token); Role role = Role.fromKey(roleString); - UserPrincipal userPrincipal = new UserPrincipal(providerId, role); + UserPrincipal userPrincipal = new UserPrincipal(userId, role); return new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities()); } } \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/oauth/OAuth2LoginSuccessHandler.java b/src/main/java/org/quizly/quizly/oauth/OAuth2LoginSuccessHandler.java index 0138041..a3f927c 100644 --- a/src/main/java/org/quizly/quizly/oauth/OAuth2LoginSuccessHandler.java +++ b/src/main/java/org/quizly/quizly/oauth/OAuth2LoginSuccessHandler.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -10,23 +9,29 @@ import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.quizly.quizly.core.domin.entity.RefreshToken; +import org.quizly.quizly.core.domin.entity.User; import org.quizly.quizly.core.domin.repository.RefreshTokenRepository; +import org.quizly.quizly.core.domin.repository.UserRepository; import org.quizly.quizly.jwt.JwtProvider; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -// TODO: 프로덕션 환경에서는 secure 플래그를 true로 설정해야 합니다. (하단 주석 코드) +@Slf4j @RequiredArgsConstructor @Component public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; private final ObjectMapper objectMapper; @Value("${jwt.refresh-token-expiration}") @@ -42,33 +47,40 @@ public void onAuthenticationSuccess( UserPrincipal customUserDetails = (UserPrincipal) authentication.getPrincipal(); - String providerId = customUserDetails.getProviderId(); + Long userId = customUserDetails.getUserId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException("User not found")); String role = authentication.getAuthorities().stream() .findFirst() .map(GrantedAuthority::getAuthority) .orElseThrow(() -> new IllegalStateException("User has no authorities")); - String accessToken = jwtProvider.generateAccessToken(providerId, role); - String refreshToken = jwtProvider.generateRefreshToken(providerId); + String accessToken = jwtProvider.generateAccessToken(userId, role); + String refreshToken = jwtProvider.generateRefreshToken(userId); + + log.info("[OAuth2LoginSuccessHandler] Login successful - userId: {}, provider: {}", user.getId(), user.getProvider()); - Optional refreshTokenOptional = refreshTokenRepository.findByProviderId(providerId); + Optional refreshTokenOptional = refreshTokenRepository.findByUserId(userId); if (refreshTokenOptional.isPresent()) { refreshTokenOptional.get().setToken(refreshToken); } else { - refreshTokenRepository.save(new RefreshToken(providerId, customUserDetails.getName(), refreshToken)); + refreshTokenRepository.save(new RefreshToken(userId, refreshToken)); } - Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); - refreshTokenCookie.setHttpOnly(true); - refreshTokenCookie.setPath("/"); - refreshTokenCookie.setMaxAge((int) (refreshTokenExpiration / 1000)); - // refreshTokenCookie.setSecure(true); + ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("Lax") + .maxAge(refreshTokenExpiration / 1000) + .build(); Map tokenMap = new HashMap<>(); tokenMap.put("accessToken", accessToken); - response.addCookie(refreshTokenCookie); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(tokenMap)); diff --git a/src/main/java/org/quizly/quizly/oauth/UserPrincipal.java b/src/main/java/org/quizly/quizly/oauth/UserPrincipal.java index 6d00236..9f7a0ce 100644 --- a/src/main/java/org/quizly/quizly/oauth/UserPrincipal.java +++ b/src/main/java/org/quizly/quizly/oauth/UserPrincipal.java @@ -1,35 +1,21 @@ package org.quizly.quizly.oauth; -import jakarta.persistence.Column; import java.util.ArrayList; import java.util.Collection; import java.util.Map; import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.quizly.quizly.core.domin.entity.User; -import org.quizly.quizly.core.domin.entity.User.Provider; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.user.OAuth2User; @Getter public class UserPrincipal implements OAuth2User { - private final Provider provider; - private final String providerId; - private final String name; + private final Long userId; private final User.Role role; - public UserPrincipal(Provider provider, String providerId, String name, User.Role role) { - this.provider = provider; - this.providerId = providerId; - this.name = name; - this.role = role; - } - - public UserPrincipal(String providerId, User.Role role) { - this.provider = null; - this.providerId = providerId; - this.name = null; + public UserPrincipal(Long userId, User.Role role) { + this.userId = userId; this.role = role; } @@ -52,7 +38,7 @@ public String getAuthority() { @Override public String getName() { - return name; + return String.valueOf(userId); } } \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java b/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java index 4bfecb2..1538030 100644 --- a/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java +++ b/src/main/java/org/quizly/quizly/oauth/controller/delete/RevokeRefreshTokenController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.quizly.quizly.configuration.swagger.ApiErrorCode; import org.quizly.quizly.core.application.BaseResponse; import org.quizly.quizly.core.exception.error.GlobalErrorCode; @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequiredArgsConstructor @Tag(name = "Auth", description = "인증") @@ -39,7 +41,7 @@ public ResponseEntity revokeRefreshToken( ) { var serviceResponse = revokeRefreshTokenService.execute( RevokeRefreshTokenRequest.builder() - .providerId(userPrincipal.getProviderId()) + .userId(userPrincipal.getUserId()) .build()); if (!serviceResponse.isSuccess()) { @@ -50,6 +52,8 @@ public ResponseEntity revokeRefreshToken( }); } + log.info("[AUTH] Logout - userId: {}", userPrincipal.getUserId()); + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") .httpOnly(true) .secure(true) diff --git a/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java b/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java index 82daa2d..85aa1a8 100644 --- a/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java +++ b/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java @@ -28,9 +28,9 @@ public class OAuth2LoginUserService extends DefaultOAuth2UserService { public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); - log.info("oAuth2User: {}", oAuth2User); String registrationId = userRequest.getClientRegistration().getRegistrationId(); + log.info("[OAuth2LoginUserService] OAuth2 user loading - provider: {}", registrationId); OAuth2UserInfo oAuth2UserInfo = getOAuth2UserInfo(registrationId, oAuth2User); if (oAuth2UserInfo == null) { return null; @@ -38,7 +38,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic User user = processUser(oAuth2UserInfo); - return new UserPrincipal(user.getProvider(), user.getProviderId(), user.getName(), user.getRole()); + return new UserPrincipal(user.getId(), user.getRole()); } private OAuth2UserInfo getOAuth2UserInfo(String registrationId, OAuth2User oAuth2User) { diff --git a/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java b/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java index 3f28948..36d1134 100644 --- a/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java +++ b/src/main/java/org/quizly/quizly/oauth/service/RevokeRefreshTokenService.java @@ -31,16 +31,16 @@ public class RevokeRefreshTokenService implements BaseService { - PROVIDER_ID_MISSING(HttpStatus.BAD_REQUEST, "사용자 인증 정보가 제공되지 않았습니다."); + USER_ID_MISSING(HttpStatus.BAD_REQUEST, "사용자 인증 정보가 제공되지 않았습니다."); private final HttpStatus httpStatus; private final String message; @@ -68,11 +68,11 @@ public DomainException toException() { @AllArgsConstructor @ToString public static class RevokeRefreshTokenRequest implements BaseRequest { - private String providerId; + private Long userId; @Override public boolean isValid() { - return providerId != null && !providerId.isEmpty(); + return userId != null; } } From 912f98192be0abdc4fe13ae82d52a5b4bbeb1ad7 Mon Sep 17 00:00:00 2001 From: fnzl54 Date: Fri, 6 Feb 2026 16:31:42 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor(#110):=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20API=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReissueAccessTokenController.java} | 47 +++++++----- ...e.java => ReissueAccessTokenResponse.java} | 2 +- ...ce.java => ReissueAccessTokenService.java} | 73 ++++++++++--------- 3 files changed, 69 insertions(+), 53 deletions(-) rename src/main/java/org/quizly/quizly/oauth/controller/{AccessTokenReissueController.java => post/ReissueAccessTokenController.java} (51%) rename src/main/java/org/quizly/quizly/oauth/dto/response/{AccessTokenReissueResponse.java => ReissueAccessTokenResponse.java} (91%) rename src/main/java/org/quizly/quizly/oauth/service/{AccessTokenReissueService.java => ReissueAccessTokenService.java} (64%) diff --git a/src/main/java/org/quizly/quizly/oauth/controller/AccessTokenReissueController.java b/src/main/java/org/quizly/quizly/oauth/controller/post/ReissueAccessTokenController.java similarity index 51% rename from src/main/java/org/quizly/quizly/oauth/controller/AccessTokenReissueController.java rename to src/main/java/org/quizly/quizly/oauth/controller/post/ReissueAccessTokenController.java index 79ff549..351570f 100644 --- a/src/main/java/org/quizly/quizly/oauth/controller/AccessTokenReissueController.java +++ b/src/main/java/org/quizly/quizly/oauth/controller/post/ReissueAccessTokenController.java @@ -1,19 +1,22 @@ -package org.quizly.quizly.oauth.controller; +package org.quizly.quizly.oauth.controller.post; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.quizly.quizly.configuration.swagger.ApiErrorCode; import org.quizly.quizly.core.application.BaseResponse; import org.quizly.quizly.core.exception.error.GlobalErrorCode; -import org.quizly.quizly.oauth.dto.response.AccessTokenReissueResponse; -import org.quizly.quizly.oauth.service.AccessTokenReissueService; -import org.quizly.quizly.oauth.service.AccessTokenReissueService.AccessTokenReissueErrorCode; -import org.quizly.quizly.oauth.service.AccessTokenReissueService.AccessTokenReissueRequest; +import org.quizly.quizly.jwt.JwtProvider; +import org.quizly.quizly.oauth.dto.response.ReissueAccessTokenResponse; +import org.quizly.quizly.oauth.service.ReissueAccessTokenService; +import org.quizly.quizly.oauth.service.ReissueAccessTokenService.ReissueAccessTokenErrorCode; +import org.quizly.quizly.oauth.service.ReissueAccessTokenService.ReissueAccessTokenRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; @@ -22,9 +25,12 @@ @RestController @RequiredArgsConstructor @Tag(name = "Auth", description = "인증") -public class AccessTokenReissueController { +public class ReissueAccessTokenController { - private final AccessTokenReissueService accessTokenReissueService; + private final ReissueAccessTokenService reissueAccessTokenService; + + @Value("${jwt.refresh-token-expiration}") + private Long refreshTokenExpiration; @Operation( summary = "accessToken 재발급 API", @@ -32,14 +38,14 @@ public class AccessTokenReissueController { operationId = "/auth/reissue" ) @PostMapping("/auth/reissue") - @ApiErrorCode(errorCodes = {GlobalErrorCode.class, AccessTokenReissueErrorCode.class}) - public ResponseEntity refreshAccessToken(@CookieValue("refreshToken") @Parameter(hidden = true) String refreshToken, HttpServletResponse response) { + @ApiErrorCode(errorCodes = {GlobalErrorCode.class, ReissueAccessTokenErrorCode.class}) + public ResponseEntity refreshAccessToken(@CookieValue("refreshToken") @Parameter(hidden = true) String refreshToken, HttpServletResponse response) { - AccessTokenReissueRequest accessTokenReissueRequest = AccessTokenReissueRequest.builder() + ReissueAccessTokenRequest reissueAccessTokenRequest = ReissueAccessTokenRequest.builder() .refreshToken(refreshToken) .build(); - AccessTokenReissueService.AccessTokenReissueResponse serviceResponse = accessTokenReissueService.execute(accessTokenReissueRequest); + ReissueAccessTokenService.ReissueAccessTokenResponse serviceResponse = reissueAccessTokenService.execute(reissueAccessTokenRequest); if (!serviceResponse.isSuccess()) { Optional.of(serviceResponse) @@ -49,13 +55,20 @@ public ResponseEntity refreshAccessToken(@CookieValu }); } - Cookie newRefreshTokenCookie = new Cookie("refreshToken", serviceResponse.getRefreshToken()); - newRefreshTokenCookie.setHttpOnly(true); - newRefreshTokenCookie.setPath("/"); - response.addCookie(newRefreshTokenCookie); + String newRefreshToken = serviceResponse.getRefreshToken(); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", newRefreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("Lax") + .maxAge((int) (refreshTokenExpiration / 1000)) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); return ResponseEntity.ok( - AccessTokenReissueResponse.builder() + ReissueAccessTokenResponse.builder() .accessToken(serviceResponse.getAccessToken()) .build()); } diff --git a/src/main/java/org/quizly/quizly/oauth/dto/response/AccessTokenReissueResponse.java b/src/main/java/org/quizly/quizly/oauth/dto/response/ReissueAccessTokenResponse.java similarity index 91% rename from src/main/java/org/quizly/quizly/oauth/dto/response/AccessTokenReissueResponse.java rename to src/main/java/org/quizly/quizly/oauth/dto/response/ReissueAccessTokenResponse.java index 8d0ddf5..3eb5ba1 100644 --- a/src/main/java/org/quizly/quizly/oauth/dto/response/AccessTokenReissueResponse.java +++ b/src/main/java/org/quizly/quizly/oauth/dto/response/ReissueAccessTokenResponse.java @@ -17,7 +17,7 @@ @AllArgsConstructor @ToString @Schema(description = "액세스 토큰 재발급 응답") -public class AccessTokenReissueResponse extends BaseResponse { +public class ReissueAccessTokenResponse extends BaseResponse { @Schema(description = "새로 발급된 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") private String accessToken; diff --git a/src/main/java/org/quizly/quizly/oauth/service/AccessTokenReissueService.java b/src/main/java/org/quizly/quizly/oauth/service/ReissueAccessTokenService.java similarity index 64% rename from src/main/java/org/quizly/quizly/oauth/service/AccessTokenReissueService.java rename to src/main/java/org/quizly/quizly/oauth/service/ReissueAccessTokenService.java index 292a5dd..a41e13c 100644 --- a/src/main/java/org/quizly/quizly/oauth/service/AccessTokenReissueService.java +++ b/src/main/java/org/quizly/quizly/oauth/service/ReissueAccessTokenService.java @@ -21,8 +21,8 @@ import org.quizly.quizly.core.exception.error.BaseErrorCode; import org.quizly.quizly.jwt.JwtProvider; import org.quizly.quizly.jwt.error.AuthErrorCode; -import org.quizly.quizly.oauth.service.AccessTokenReissueService.AccessTokenReissueRequest; -import org.quizly.quizly.oauth.service.AccessTokenReissueService.AccessTokenReissueResponse; +import org.quizly.quizly.oauth.service.ReissueAccessTokenService.ReissueAccessTokenRequest; +import org.quizly.quizly.oauth.service.ReissueAccessTokenService.ReissueAccessTokenResponse; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,68 +31,71 @@ @Service @Transactional @RequiredArgsConstructor -public class AccessTokenReissueService implements BaseService { +public class ReissueAccessTokenService implements BaseService { private final JwtProvider jwtProvider; private final RefreshTokenRepository refreshTokenRepository; private final UserRepository userRepository; @Override - public AccessTokenReissueResponse execute(AccessTokenReissueRequest accessTokenReissueRequest) { - String refreshToken = accessTokenReissueRequest.getRefreshToken(); + public ReissueAccessTokenResponse execute(ReissueAccessTokenRequest reissueAccessTokenRequest) { + String refreshToken = reissueAccessTokenRequest.getRefreshToken(); AuthErrorCode errorCode = jwtProvider.validateToken(refreshToken); if (errorCode != null) { if (errorCode == AuthErrorCode.EXPIRED_ACCESS_TOKEN) { - return AccessTokenReissueResponse.builder() + return ReissueAccessTokenResponse.builder() .success(false) - .errorCode(AccessTokenReissueErrorCode.REFRESH_TOKEN_EXPIRED) + .errorCode(ReissueAccessTokenErrorCode.REFRESH_TOKEN_EXPIRED) .build(); } else { - return AccessTokenReissueResponse.builder() + return ReissueAccessTokenResponse.builder() .success(false) - .errorCode(AccessTokenReissueErrorCode.REFRESH_TOKEN_INVALID) + .errorCode(ReissueAccessTokenErrorCode.REFRESH_TOKEN_INVALID) .build(); } } - String providerId = jwtProvider.getProviderId(refreshToken); + Long userId = jwtProvider.getUserId(refreshToken); - Optional userOptional = userRepository.findByProviderId(providerId); - if (userOptional.isEmpty()) { - log.warn("[AccessTokenReissueService] User not found for providerId derived from a refresh token. ProviderId: {}", providerId); - return AccessTokenReissueResponse.builder() + Optional refreshTokenOptional = refreshTokenRepository.findByUserId(userId); + if (refreshTokenOptional.isEmpty()) { + return ReissueAccessTokenResponse.builder() .success(false) - .errorCode(AccessTokenReissueErrorCode.USER_NOT_FOUND) + .errorCode(ReissueAccessTokenErrorCode.REFRESH_TOKEN_NOT_FOUND) .build(); } - User user = userOptional.get(); - - String role = user.getRole().getKey(); + RefreshToken savedRefreshToken = refreshTokenOptional.get(); - Optional refreshTokenOptional = refreshTokenRepository.findByProviderId(providerId); - if (refreshTokenOptional.isEmpty()) { - return AccessTokenReissueResponse.builder() + if (!savedRefreshToken.getToken().equals(refreshToken)) { + log.warn("[ReissueAccessTokenService] Token mismatch detected - userId: {}", userId); + return ReissueAccessTokenResponse.builder() .success(false) - .errorCode(AccessTokenReissueErrorCode.REFRESH_TOKEN_NOT_FOUND) + .errorCode(ReissueAccessTokenErrorCode.REFRESH_TOKEN_INVALID) .build(); } - RefreshToken savedRefreshToken = refreshTokenOptional.get(); - if (!savedRefreshToken.getToken().equals(refreshToken)) { - log.warn("Refresh Token Mismatch Detected. ProviderId: {}", providerId); - return AccessTokenReissueResponse.builder() + Optional userOptional = userRepository.findById(userId); + if (userOptional.isEmpty()) { + log.warn("[ReissueAccessTokenService] User Not - userId: {}", userId); + return ReissueAccessTokenResponse.builder() .success(false) - .errorCode(AccessTokenReissueErrorCode.REFRESH_TOKEN_INVALID) + .errorCode(ReissueAccessTokenErrorCode.USER_NOT_FOUND) .build(); } - String newAccessToken = jwtProvider.generateAccessToken(providerId, role); - String newRefreshToken = jwtProvider.generateRefreshToken(providerId); + User user = userOptional.get(); + + String role = user.getRole().name(); + + String newAccessToken = jwtProvider.generateAccessToken(userId, role); + String newRefreshToken = jwtProvider.generateRefreshToken(userId); savedRefreshToken.setToken(newRefreshToken); - return AccessTokenReissueResponse.builder() + log.info("[ReissueAccessTokenService] Token reissued - userId: {}", userId); + + return ReissueAccessTokenResponse.builder() .accessToken(newAccessToken) .refreshToken(newRefreshToken) .build(); @@ -100,11 +103,11 @@ public AccessTokenReissueResponse execute(AccessTokenReissueRequest accessTokenR @Getter @RequiredArgsConstructor - public enum AccessTokenReissueErrorCode implements BaseErrorCode { + public enum ReissueAccessTokenErrorCode implements BaseErrorCode { REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰을 찾을 수 없습니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "토큰에 해당하는 사용자를 찾을 수 없습니다."), - REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."); + REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "사용자를 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String message; @@ -121,7 +124,7 @@ public DomainException toException() { @NoArgsConstructor @AllArgsConstructor @ToString - public static class AccessTokenReissueRequest implements BaseRequest { + public static class ReissueAccessTokenRequest implements BaseRequest { private String refreshToken; @Override @@ -136,7 +139,7 @@ public boolean isValid() { @NoArgsConstructor @AllArgsConstructor @ToString - public static class AccessTokenReissueResponse extends BaseResponse { + public static class ReissueAccessTokenResponse extends BaseResponse { private String accessToken; private String refreshToken; } From 27453ad707a5614aa7c857de799a629472b2b342 Mon Sep 17 00:00:00 2001 From: fnzl54 Date: Fri, 6 Feb 2026 16:32:00 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor(#110):=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=95=84=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/dto/response/KakaoUserInfo.java | 42 +++++++++++++------ .../oauth/dto/response/NaverUserInfo.java | 4 +- .../oauth/dto/response/OAuth2UserInfo.java | 2 +- .../oauth/service/OAuth2LoginUserService.java | 6 +-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/quizly/quizly/oauth/dto/response/KakaoUserInfo.java b/src/main/java/org/quizly/quizly/oauth/dto/response/KakaoUserInfo.java index 076342e..e1078b9 100644 --- a/src/main/java/org/quizly/quizly/oauth/dto/response/KakaoUserInfo.java +++ b/src/main/java/org/quizly/quizly/oauth/dto/response/KakaoUserInfo.java @@ -1,6 +1,8 @@ package org.quizly.quizly.oauth.dto.response; import java.util.Map; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; public class KakaoUserInfo implements OAuth2UserInfo { @@ -11,6 +13,28 @@ public KakaoUserInfo(Map attributes) { this.attributes = attributes; } + @SuppressWarnings("unchecked") + private Map getNestedMap(String key) { + Object value = attributes.get(key); + if (!(value instanceof Map)) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_response", + "Missing '" + key + "' field in Kakao API response", null)); + } + return (Map) value; + } + + private String getNestedAttribute(String parentKey, String childKey) { + Map parent = getNestedMap(parentKey); + Object value = parent.get(childKey); + if (value == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_response", + "Missing required field in Kakao API response: " + childKey + " (필수 동의 항목)", null)); + } + return value.toString(); + } + @Override public String getProvider() { return "kakao"; @@ -20,26 +44,20 @@ public String getProvider() { public String getProviderId() { Object id = attributes.get("id"); if (id == null) { - throw new IllegalArgumentException("카카오 API 응답에 'id' 필드가 없습니다."); + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_response", + "Missing required field in Kakao API response: id", null)); } return id.toString(); } @Override public String getEmail() { - if (attributes.get("kakao_account") instanceof Map account) { - Object email = account.get("email"); - return email != null ? email.toString() : null; - } - return null; + return getNestedAttribute("kakao_account", "email"); } @Override - public String getName() { - if (attributes.get("properties") instanceof Map props) { - Object nickname = props.get("nickname"); - return nickname != null ? nickname.toString() : null; - } - return null; + public String getNickname() { + return getNestedAttribute("properties", "nickname"); } } diff --git a/src/main/java/org/quizly/quizly/oauth/dto/response/NaverUserInfo.java b/src/main/java/org/quizly/quizly/oauth/dto/response/NaverUserInfo.java index f277160..6786052 100644 --- a/src/main/java/org/quizly/quizly/oauth/dto/response/NaverUserInfo.java +++ b/src/main/java/org/quizly/quizly/oauth/dto/response/NaverUserInfo.java @@ -42,7 +42,7 @@ public String getEmail() { } @Override - public String getName() { - return getAttribute("name"); + public String getNickname() { + return getAttribute("nickname"); } } \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/oauth/dto/response/OAuth2UserInfo.java b/src/main/java/org/quizly/quizly/oauth/dto/response/OAuth2UserInfo.java index 58ccb07..97cd0f3 100644 --- a/src/main/java/org/quizly/quizly/oauth/dto/response/OAuth2UserInfo.java +++ b/src/main/java/org/quizly/quizly/oauth/dto/response/OAuth2UserInfo.java @@ -8,5 +8,5 @@ public interface OAuth2UserInfo { String getEmail(); - String getName(); + String getNickname(); } diff --git a/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java b/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java index 85aa1a8..5b58a9f 100644 --- a/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java +++ b/src/main/java/org/quizly/quizly/oauth/service/OAuth2LoginUserService.java @@ -72,8 +72,8 @@ private User createUser(OAuth2UserInfo oAuth2UserInfo) { userEntity.setProviderId(oAuth2UserInfo.getProviderId()); userEntity.setEmail(oAuth2UserInfo.getEmail()); - userEntity.setName(oAuth2UserInfo.getName()); - userEntity.setNickName(oAuth2UserInfo.getName()); + userEntity.setName(oAuth2UserInfo.getNickname()); + userEntity.setNickName(oAuth2UserInfo.getNickname()); userEntity.setRole(Role.USER); userRepository.save(userEntity); return userEntity; @@ -81,7 +81,7 @@ private User createUser(OAuth2UserInfo oAuth2UserInfo) { private User updateUser(User user, OAuth2UserInfo oAuth2UserInfo) { user.setEmail(oAuth2UserInfo.getEmail()); - user.setName(oAuth2UserInfo.getName()); + user.setName(oAuth2UserInfo.getNickname()); userRepository.save(user); return user; } From 52ae32d28d9a7227140da6c9cbf765356c5c108a Mon Sep 17 00:00:00 2001 From: yeonchaepark Date: Thu, 29 Jan 2026 15:38:01 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(#103):=20slack=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../listener/BatchFailureAlertListener.java | 54 ++++++++++ .../BatchFailureNotificationMessage.java | 30 ++++++ .../configuration/RestClientConfig.java | 31 ++++++ .../notification/NotificationMessage.java | 6 ++ .../notification/NotificationProvider.java | 5 + .../slack/dto/Request/SlackRequest.java | 4 + .../service/NoOpSlackNotificationService.java | 15 +++ .../service/SlackNotificationService.java | 102 ++++++++++++++++++ src/main/resources/application-dev.yml | 2 + 10 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/quizly/quizly/batch/listener/BatchFailureAlertListener.java create mode 100644 src/main/java/org/quizly/quizly/batch/message/BatchFailureNotificationMessage.java create mode 100644 src/main/java/org/quizly/quizly/configuration/RestClientConfig.java create mode 100644 src/main/java/org/quizly/quizly/core/notification/NotificationMessage.java create mode 100644 src/main/java/org/quizly/quizly/core/notification/NotificationProvider.java create mode 100644 src/main/java/org/quizly/quizly/external/slack/dto/Request/SlackRequest.java create mode 100644 src/main/java/org/quizly/quizly/external/slack/service/NoOpSlackNotificationService.java create mode 100644 src/main/java/org/quizly/quizly/external/slack/service/SlackNotificationService.java diff --git a/build.gradle b/build.gradle index e94a077..f0db9a8 100644 --- a/build.gradle +++ b/build.gradle @@ -58,8 +58,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-batch' testImplementation 'org.springframework.batch:spring-batch-test' - // Apache PDF Box + // Apache implementation group: 'org.apache.pdfbox', name: 'pdfbox', version: '2.0.31' + implementation "org.apache.httpcomponents.client5:httpclient5" // Open AI implementation("com.openai:openai-java:4.15.0") diff --git a/src/main/java/org/quizly/quizly/batch/listener/BatchFailureAlertListener.java b/src/main/java/org/quizly/quizly/batch/listener/BatchFailureAlertListener.java new file mode 100644 index 0000000..f7f0cc3 --- /dev/null +++ b/src/main/java/org/quizly/quizly/batch/listener/BatchFailureAlertListener.java @@ -0,0 +1,54 @@ +package org.quizly.quizly.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quizly.quizly.batch.message.BatchFailureNotificationMessage; +import org.quizly.quizly.core.notification.NotificationProvider; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BatchFailureAlertListener implements JobExecutionListener { + + private final NotificationProvider notificationProvider; + + @Override + public void afterJob(JobExecution jobExecution) { + if (jobExecution.getStatus() == BatchStatus.FAILED) { + + String jobName = jobExecution.getJobInstance().getJobName(); + + String reason = jobExecution.getAllFailureExceptions().isEmpty() + ? "unknown" + : jobExecution.getAllFailureExceptions().get(0).getMessage(); + + String step = jobExecution.getStepExecutions().stream() + .filter(se -> se.getStatus() == BatchStatus.FAILED) + .map(se -> se.getStepName() + " (" + se.getExitStatus().getExitCode() + ")") + .collect(Collectors.joining(", ")); + + + String parameters = formatJobParameters(jobExecution); + + notificationProvider.send( + new BatchFailureNotificationMessage(jobName, reason, step, parameters) + ); + } + } + + private String formatJobParameters(JobExecution jobExecution){ + return jobExecution.getJobParameters() + .getParameters() + .entrySet() + .stream() + .map(e -> e.getKey() + "=" + e.getValue().getValue()) + .collect(Collectors.joining(", ")); + } + +} \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/batch/message/BatchFailureNotificationMessage.java b/src/main/java/org/quizly/quizly/batch/message/BatchFailureNotificationMessage.java new file mode 100644 index 0000000..1acb6af --- /dev/null +++ b/src/main/java/org/quizly/quizly/batch/message/BatchFailureNotificationMessage.java @@ -0,0 +1,30 @@ +package org.quizly.quizly.batch.message; + +import org.quizly.quizly.core.notification.NotificationMessage; + +public class BatchFailureNotificationMessage implements NotificationMessage { + + private final String jobName; + private final String reason; + + private final String step; + + private final String parameters; + + public BatchFailureNotificationMessage(String jobName, String reason, String step, String parameters) { + this.jobName = jobName; + this.reason = reason; + this.step = step; + this.parameters = parameters; + } + + @Override + public String title() { + return "Batch Failure"; + } + + @Override + public String body() { + return "job=" + jobName + "\nreason=" + reason + "\nstep=" + step + "\nparameters=" + parameters; + } +} diff --git a/src/main/java/org/quizly/quizly/configuration/RestClientConfig.java b/src/main/java/org/quizly/quizly/configuration/RestClientConfig.java new file mode 100644 index 0000000..3f3ec71 --- /dev/null +++ b/src/main/java/org/quizly/quizly/configuration/RestClientConfig.java @@ -0,0 +1,31 @@ +package org.quizly.quizly.configuration; + +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.util.Timeout; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestClientConfig { + + @Bean + public RestTemplate restTemplate() { + + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(3)); + + CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(RequestConfig.custom() + .setResponseTimeout(Timeout.ofSeconds(3)) + .build()) + .build(); + factory.setHttpClient(httpClient); + return new RestTemplate(factory); + } +} \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/core/notification/NotificationMessage.java b/src/main/java/org/quizly/quizly/core/notification/NotificationMessage.java new file mode 100644 index 0000000..10fe0e2 --- /dev/null +++ b/src/main/java/org/quizly/quizly/core/notification/NotificationMessage.java @@ -0,0 +1,6 @@ +package org.quizly.quizly.core.notification; + +public interface NotificationMessage { + String title(); + String body(); +} diff --git a/src/main/java/org/quizly/quizly/core/notification/NotificationProvider.java b/src/main/java/org/quizly/quizly/core/notification/NotificationProvider.java new file mode 100644 index 0000000..6d09eea --- /dev/null +++ b/src/main/java/org/quizly/quizly/core/notification/NotificationProvider.java @@ -0,0 +1,5 @@ +package org.quizly.quizly.core.notification; + +public interface NotificationProvider { + void send(NotificationMessage message); +} diff --git a/src/main/java/org/quizly/quizly/external/slack/dto/Request/SlackRequest.java b/src/main/java/org/quizly/quizly/external/slack/dto/Request/SlackRequest.java new file mode 100644 index 0000000..3dccdcc --- /dev/null +++ b/src/main/java/org/quizly/quizly/external/slack/dto/Request/SlackRequest.java @@ -0,0 +1,4 @@ +package org.quizly.quizly.external.slack.dto.Request; + +public record SlackRequest(String text) { +} \ No newline at end of file diff --git a/src/main/java/org/quizly/quizly/external/slack/service/NoOpSlackNotificationService.java b/src/main/java/org/quizly/quizly/external/slack/service/NoOpSlackNotificationService.java new file mode 100644 index 0000000..8247c59 --- /dev/null +++ b/src/main/java/org/quizly/quizly/external/slack/service/NoOpSlackNotificationService.java @@ -0,0 +1,15 @@ +package org.quizly.quizly.external.slack.service; + +import org.quizly.quizly.core.notification.NotificationMessage; +import org.quizly.quizly.core.notification.NotificationProvider; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Profile({"local"}) +@Service +public class NoOpSlackNotificationService implements NotificationProvider { + @Override + public void send(NotificationMessage message) { + + } +} diff --git a/src/main/java/org/quizly/quizly/external/slack/service/SlackNotificationService.java b/src/main/java/org/quizly/quizly/external/slack/service/SlackNotificationService.java new file mode 100644 index 0000000..b3272e1 --- /dev/null +++ b/src/main/java/org/quizly/quizly/external/slack/service/SlackNotificationService.java @@ -0,0 +1,102 @@ +package org.quizly.quizly.external.slack.service; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; +import org.quizly.quizly.core.application.BaseRequest; +import org.quizly.quizly.core.application.BaseResponse; +import org.quizly.quizly.core.application.BaseService; +import org.quizly.quizly.core.exception.DomainException; +import org.quizly.quizly.core.exception.error.BaseErrorCode; +import org.quizly.quizly.core.notification.NotificationMessage; +import org.quizly.quizly.core.notification.NotificationProvider; +import org.quizly.quizly.external.slack.dto.Request.SlackRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +@Profile({"dev", "prod"}) +@Slf4j +@Service +@RequiredArgsConstructor +public class SlackNotificationService implements NotificationProvider, BaseService { + + @Value("${notification.slack.webhook-url}") + private String webhookUrl; + + private final RestTemplate restTemplate; + private final Environment environment; + + @Override + public void send(NotificationMessage message) { + if (message == null) { + return; + } + String text = format(message); + this.execute(new NotificationRequest(text)); + } + + private String format(NotificationMessage message) { + String[] profiles = environment.getActiveProfiles(); + String profile = profiles.length > 0 ? profiles[0].toUpperCase() : "UNKNOWN"; + return "[" + profile + "] [" + message.title() + "]\n" + message.body(); + } + @Override + public NotificationResponse execute(NotificationRequest request) { + if (request == null || !request.isValid()) { + return NotificationResponse.builder().success(false).build(); + } + + try { + SlackRequest slackRequest = new SlackRequest(request.getMessage()); + restTemplate.postForEntity(webhookUrl, slackRequest, String.class); + + return NotificationResponse.builder() + .success(true) + .build(); + } catch (RestClientException e) { + log.error("[SlackNotificationService] 전송 실패", e); + return NotificationResponse.builder() + .success(false) + .errorCode(NotificationErrorCode.SEND_FAILED) + .build(); + } + } + @Getter + @RequiredArgsConstructor + public enum NotificationErrorCode implements BaseErrorCode { + SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송 중 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public DomainException toException() { + return new DomainException(httpStatus, this); + } + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class NotificationRequest implements BaseRequest { + private String message; + + @Override + public boolean isValid() { + return message != null && !message.isBlank(); + } + } + + @Getter + @SuperBuilder + @NoArgsConstructor + public static class NotificationResponse extends BaseResponse { + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6d5c60e..6260548 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,6 +2,8 @@ server: name: Quizly spring: + profiles: + active: dev datasource: url: ${DB_URL} username: ${DB_USERNAME}