diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index 14b15ec7..cc4b7ecb 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -80,36 +80,11 @@ jobs: - name: Create test .env file working-directory: backend run: | - cat > .env << 'EOF' - # Datasource 설정 (application-test.yml에서 참조) - TEST_DATASOURCE_URL=jdbc:h2:mem:db_test;MODE=MySQL - TEST_DATASOURCE_USERNAME=sa - TEST_DATASOURCE_PASSWORD= - TEST_DATASOURCE_DRIVER=org.h2.Driver - - # JPA 설정 (application-test.yml에서 참조) - TEST_JPA_HIBERNATE_DDL_AUTO=create-drop - - email_address=${{ secrets.EMAIL_ADDRESS }} - send_email_password=${{ secrets.EMAIL_PASSWORD }} - send_email_address=${{ secrets.SEND_EMAIL_ADDRESS }} - - # Redis 설정 (application-test.yml에서 참조, GitHub Actions 서비스 사용) - TEST_REDIS_HOST=localhost - TEST_REDIS_PORT=6379 - TEST_REDIS_PASSWORD= - - # Qdrant - TEST_QDRANT_HOST=localhost - TEST_QDRANT_PORT=6333 - - # CI/CD 환경에서는 Embedded Redis 끄기 - SPRING_DATA_REDIS_EMBEDDED=false - - # JWT 설정 (application-test.yml에서 참조) - CUSTOM_JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} - CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=3600 - EOF + set -euo pipefail + install -d -m 700 . + echo "${{ secrets.ENV_BASE64 }}" | base64 -d > .env + chmod 600 .env + test -s .env || { echo ".env is empty"; exit 1; } - name: Run unit, and domain tests run: ${{ matrix.gradle_cmd }} clean test @@ -219,19 +194,6 @@ jobs: run: | echo "IMAGE_PREFIX=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - name: Create prod .env file - run: | - cat > .env << 'EOF' - SPRING_PROFILES_ACTIVE=prod - PROD_DATASOURCE_URL=jdbc:mysql://mysql:3306/${{ secrets.DB_NAME }} - PROD_DATASOURCE_USERNAME=${{ secrets.DB_USER }} - PROD_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }} - - PROD_REDIS_HOST=redis - PROD_REDIS_PORT=6379 - PROD_REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} - EOF - - name: AWS SSM Send-Command uses: peterkimzz/aws-ssm-send-command@master id: ssm @@ -243,7 +205,7 @@ jobs: working-directory: / comment: Deploy command: | - set -xe + set -euo pipefail echo "===== 현재 실행 중인 컨테이너 =====" docker ps -a || true @@ -251,36 +213,16 @@ jobs: docker stop app 2>/dev/null || true docker rm app 2>/dev/null || true - # EC2 내부에서 prod.env 파일 생성 (기존 파일 있으면 덮어쓰기) - mkdir -p /home/ec2-user/configs - cat > /home/ec2-user/configs/prod.env << 'EOF' - SPRING_PROFILES_ACTIVE=prod - - CUSTOM_JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} - CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=3600 - - PROD_DATASOURCE_URL=jdbc:mysql://mysql:3306/${{ secrets.DB_NAME }}?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul - PROD_DATASOURCE_DRIVER=com.mysql.cj.jdbc.Driver - PROD_DATASOURCE_USERNAME=root - PROD_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }} - PROD_JPA_HIBERNATE_DDL_AUTO=none - - PROD_REDIS_HOST=redis - PROD_REDIS_PORT=6379 - PROD_REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} + # EC2 내부에서 prod.env 복원 (ENV_BASE64 -> 디코드) + install -d -m 700 /home/ec2-user/configs + cat > /home/ec2-user/configs/prod.env.b64 <<'__B64__' + ${{ secrets.ENV_BASE64 }} + __B64__ - PROD_QDRANT_HOST=qdrant - PROD_QDRANT_PORT=6334 - - send_email_address=${{ secrets.SEND_EMAIL_ADDRESS }} - send_email_password=${{ secrets.SEND_EMAIL_PASSWORD }} - - PROD_SENTRY_DSN=${{ secrets.SENTRY_DSN }} - - EOF - - # 파일 권한 최소화 + base64 -d /home/ec2-user/configs/prod.env.b64 > /home/ec2-user/configs/prod.env chmod 600 /home/ec2-user/configs/prod.env + shred -u /home/ec2-user/configs/prod.env.b64 # 임시 파일 안전 삭제 + # EC2에서 GHCR 로그인 echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin diff --git a/backend/.env.default b/backend/.env.default index 81c9a2ab..b4766537 100644 --- a/backend/.env.default +++ b/backend/.env.default @@ -1,15 +1,25 @@ SPRING_PROFILES_ACTIVE=NEED_TO_SET - SPRING_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET -SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_ID=NEED_TO_SET -SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_SECRET=NEED_TO_SET -SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_ID=NEED_TO_SET -SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_SECRET=NEED_TO_SET +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID=NEED_TO_SET +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_SECRET=NEED_TO_SET +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_CLIENT_ID=NEED_TO_SET +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_CLIENT_SECRET=NEED_TO_SET CUSTOM_JWT_SECRET_KEY=NEED_TO_SET CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=NEED_TO_SET +# Email +SEND_EMAIL_ADDRESS=NEED_TO_SET +SEND_EMAIL_PASSWORD=NEED_TO_SET + +# PROD +PROD_FRONTEND_URL=NEED_TO_SET +PROD_CORS_ALLOWED_ORIGINS=NEED_TO_SET +PROD_OAUTH2_KAKAO_REDIRECT_URI=NEED_TO_SET +PROD_OAUTH2_NAVER_REDIRECT_URI=NEED_TO_SET +PROD_OAUTH2_SUCCESS_REDIRECT_URL=NEED_TO_SET +PROD_OAUTH2_FAILURE_REDIRECT_URL=NEED_TO_SET PROD_DATASOURCE_URL=NEED_TO_SET PROD_DATASOURCE_DRIVER=NEED_TO_SET PROD_DATASOURCE_USERNAME=NEED_TO_SET @@ -19,16 +29,32 @@ PROD_REDIS_HOST=NEED_TO_SET PROD_REDIS_PORT=NEED_TO_SET PROD_REDIS_PASSWORD=NEED_TO_SET +PROD_QDRANT_HOST=NEED_TO_SET +PROD_QDRANT_PORT=NEED_TO_SET + +# DEV +DEV_FRONTEND_URL=NEED_TO_SET +DEV_CORS_ALLOWED_ORIGINS=NEED_TO_SET +DEV_OAUTH2_KAKAO_REDIRECT_URI=NEED_TO_SET +DEV_OAUTH2_NAVER_REDIRECT_URI=NEED_TO_SET +DEV_OAUTH2_SUCCESS_REDIRECT_URL=NEED_TO_SET +DEV_OAUTH2_FAILURE_REDIRECT_URL=NEED_TO_SET DEV_DATASOURCE_URL=NEED_TO_SET DEV_DATASOURCE_USERNAME=NEED_TO_SET DEV_DATASOURCE_PASSWORD=NEED_TO_SET DEV_DATASOURCE_DRIVER=NEED_TO_SET DEV_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET +DEV_DATASOURCE_PORT=NEED_TO_SET +DEV_DB_ROOT_PASSWORD=NEED_TO_SET DEV_REDIS_HOST=NEED_TO_SET DEV_REDIS_PORT=NEED_TO_SET DEV_REDIS_PASSWORD=NEED_TO_SET +DEV_QDRANT_HOST=NEED_TO_SET +DEV_QDRANT_PORT=NEED_TO_SET + +# TEST TEST_DATASOURCE_URL=NEED_TO_SET TEST_DATASOURCE_USERNAME=NEED_TO_SET TEST_DATASOURCE_PASSWORD=NEED_TO_SET @@ -37,4 +63,17 @@ TEST_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET TEST_REDIS_HOST=NEED_TO_SET TEST_REDIS_PORT=NEED_TO_SET -TEST_REDIS_PASSWORD=NEED_TO_SET \ No newline at end of file +TEST_REDIS_PASSWORD=NEED_TO_SET + +# AI +OPENAI_API_KEY=NEED_TO_SET + +# Base application.yml variables (no profile-specific prefix) +SPRING_AI_VECTORSTORE_QDRANT_HOST=NEED_TO_SET +SPRING_AI_VECTORSTORE_QDRANT_PORT=NEED_TO_SET +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI=NEED_TO_SET +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_REDIRECT_URI=NEED_TO_SET +CUSTOM_CORS_ALLOWED_ORIGINS=NEED_TO_SET +CUSTOM_OAUTH2_REDIRECT_URL=NEED_TO_SET +CUSTOM_OAUTH2_FAILURE_URL=NEED_TO_SET +CUSTOM_FRONTEND_URL=NEED_TO_SET diff --git a/backend/build.gradle b/backend/build.gradle index f36d91c7..7baf1b3f 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -38,6 +38,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5' // API Documentation (문서화) diff --git a/backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java b/backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java new file mode 100644 index 00000000..2328eaeb --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java @@ -0,0 +1,11 @@ +package com.ai.lawyer.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OAuth2LoginResponse { + private boolean success; + private String message; +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java index 47312929..fa4ab059 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawSearchRequestDto.java @@ -1,13 +1,17 @@ package com.ai.lawyer.domain.law.dto; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDate; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class LawSearchRequestDto { @Schema(description = "법령명", example = "형사") diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java index 2d6bebaa..febf2aa5 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/dto/LawsDto.java @@ -3,12 +3,14 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDate; @Data @Builder @AllArgsConstructor +@NoArgsConstructor public class LawsDto { private Long id; diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index a3132d42..4a9ed7da 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -1,5 +1,6 @@ package com.ai.lawyer.domain.member.controller; +import com.ai.lawyer.domain.auth.dto.OAuth2LoginResponse; import com.ai.lawyer.domain.member.dto.*; import com.ai.lawyer.domain.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; @@ -25,6 +26,16 @@ public class MemberController { private final MemberService memberService; + // --- 상수들: 중복 문자열 리터럴 방지 --- + private static final String ANONYMOUS_USER = "anonymousUser"; + private static final String ERR_MSG_LOGIN_ID_REQUIRED = "loginId가 필요합니다. 로그인하거나 요청에 loginId를 포함해주세요."; + private static final String LOG_JWT_EXTRACT_WARN = "JWT 토큰에서 loginId 추출 중 오류: {}"; + private static final String LOG_JWT_EXTRACT_INFO = "JWT 토큰에서 loginId 추출 성공: {}"; + private static final String LOG_JWT_EXTRACT_FAIL = "JWT 토큰에서 loginId 추출 실패"; + private static final String LOG_INVALID_AUTH = "인증 정보 없음 또는 인증되지 않음"; + + // ---------------- API ---------------- + @PostMapping("/signup") @Operation(summary = "01. 회원가입", description = "새로운 회원을 등록합니다.") @ApiResponses({ @@ -33,7 +44,6 @@ public class MemberController { }) public ResponseEntity signup(@Valid @RequestBody MemberSignupRequest request, HttpServletResponse response) { log.info("회원가입 요청: email={}, name={}", request.getLoginId(), request.getName()); - MemberResponse memberResponse = memberService.signup(request, response); log.info("회원가입 및 자동 로그인 성공: memberId={}", memberResponse.getMemberId()); return ResponseEntity.status(HttpStatus.CREATED).body(memberResponse); @@ -46,344 +56,248 @@ public ResponseEntity signup(@Valid @RequestBody MemberSignupReq @ApiResponse(responseCode = "401", description = "인증 실패 (존재하지 않는 회원, 비밀번호 불일치)") }) public ResponseEntity login(@Valid @RequestBody MemberLoginRequest request, - HttpServletResponse response) { + HttpServletResponse response) { log.info("로그인 요청: email={}", request.getLoginId()); - MemberResponse memberResponse = memberService.login(request, response); log.info("로그인 성공: memberId={}", memberResponse.getMemberId()); return ResponseEntity.ok(memberResponse); } - @PostMapping("/logout") - @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") + @GetMapping("/oauth2/kakao") + @Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다.") + public void kakaoLogin(HttpServletResponse response) throws Exception { + log.info("카카오 로그인 요청"); + response.sendRedirect("/oauth2/authorization/kakao"); + } + + @GetMapping("/oauth2/naver") + @Operation(summary = "12. 네이버 로그인", description = "네이버 OAuth2 로그인을 시작합니다.") + public void naverLogin(HttpServletResponse response) throws Exception { + log.info("네이버 로그인 요청"); + response.sendRedirect("/oauth2/authorization/naver"); + } + + @GetMapping("/oauth2/callback/success") + @Operation(summary = "14. OAuth2 로그인 성공 콜백", description = "OAuth2 로그인 성공 시 호출되는 콜백 엔드포인트입니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공") + @ApiResponse(responseCode = "200", description = "로그인 성공"), }) - public ResponseEntity logout(Authentication authentication, HttpServletResponse response) { - log.info("로그아웃 요청"); + public ResponseEntity oauth2LoginSuccess() { + log.info("OAuth2 로그인 성공 콜백"); + + OAuth2LoginResponse response = OAuth2LoginResponse.builder() + .success(true) + .message("소셜 로그인에 성공했습니다.") + .build(); + + return ResponseEntity.ok(response); + } + + @GetMapping("/oauth2/callback/failure") + @Operation(summary = "15. OAuth2 로그인 실패 콜백", description = "OAuth2 로그인 실패 시 호출되는 콜백 엔드포인트입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "401", description = "로그인 실패"), + }) + public ResponseEntity oauth2LoginFailure( + @RequestParam(required = false) String error) { + log.error("OAuth2 로그인 실패: {}", error); - if (authentication != null && authentication.getName() != null) { - String loginId = authentication.getName(); + OAuth2LoginResponse response = OAuth2LoginResponse.builder() + .success(false) + .message("소셜 로그인에 실패했습니다: " + (error != null ? error : "알 수 없는 오류")) + .build(); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } + + @PostMapping("/oauth2/test") + @Operation(summary = "13. OAuth2 로그인 테스트 (개발용)", description = "OAuth2 플로우 없이 소셜 로그인 결과를 시뮬레이션합니다.") + public ResponseEntity oauth2LoginTest( + @Valid @RequestBody OAuth2LoginTestRequest request, + HttpServletResponse response) { + log.info("OAuth2 로그인 테스트: email={}, provider={}", request.getEmail(), request.getProvider()); + MemberResponse memberResponse = memberService.oauth2LoginTest(request, response); + return ResponseEntity.ok(memberResponse); + } + + @PostMapping("/logout") + @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") + public ResponseEntity logout(Authentication authentication, HttpServletResponse response) { + if (authentication != null && authentication.getDetails() != null) { + String loginId = (String) authentication.getDetails(); memberService.logout(loginId, response); - log.info("로그아웃 완료: memberId={}", loginId); + log.info("로그아웃 완료: {}", loginId); } else { - // 인증되지 않은 상태에서도 클라이언트 쿠키 클리어 처리 memberService.logout("", response); log.info("인증 정보 없이 로그아웃 완료"); } - return ResponseEntity.ok().build(); } @PostMapping("/refresh") - @Operation(summary = "04. 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), - @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰") - }) - public ResponseEntity refreshToken(HttpServletRequest request, - HttpServletResponse response) { - log.info("토큰 재발급 요청"); - - // HTTP 쿠키에서 리프레시 토큰 추출 - String refreshToken = extractRefreshTokenFromCookies(request); - - if (refreshToken == null) { - throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("리프레시 토큰이 없습니다."); + @Operation(summary = "04. 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. JwtAuthenticationFilter가 자동으로 토큰을 갱신합니다.") + public ResponseEntity refreshToken(Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null) { + log.warn("토큰 재발급 실패: {}", LOG_INVALID_AUTH); + throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); } - MemberResponse memberResponse = memberService.refreshToken(refreshToken, response); - log.info("토큰 재발급 성공: memberId={}", memberResponse.getMemberId()); - return ResponseEntity.ok(memberResponse); + Long memberId = (Long) authentication.getPrincipal(); + MemberResponse response = memberService.getMemberById(memberId); + log.info("토큰 재발급 성공: memberId={}", memberId); + return ResponseEntity.ok(response); } @DeleteMapping("/withdraw") @Operation(summary = "10. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "회원탈퇴 성공"), - @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 회원") - }) public ResponseEntity withdraw(Authentication authentication, HttpServletResponse response) { if (authentication == null || authentication.getPrincipal() == null) { throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); } - Long memberId = (Long) authentication.getPrincipal(); String loginId = (String) authentication.getDetails(); - log.info("회원탈퇴 요청: memberId={}, email={}", memberId, loginId); - memberService.withdraw(memberId); - memberService.logout(loginId, response); // 회원 탈퇴 후 세션 및 토큰 정리 - log.info("회원탈퇴 성공: memberId={}, email={}", memberId, loginId); + memberService.logout(loginId, response); return ResponseEntity.ok().build(); } @GetMapping("/me") @Operation(summary = "03. 내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자") - }) public ResponseEntity getMyInfo(Authentication authentication) { if (authentication == null || authentication.getPrincipal() == null) { throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); } - Long memberId = (Long) authentication.getPrincipal(); - log.info("내 정보 조회 요청: memberId={}", memberId); - MemberResponse response = memberService.getMemberById(memberId); - log.info("내 정보 조회 성공: memberId={}", response.getMemberId()); return ResponseEntity.ok(response); } @PostMapping("/sendEmail") - @Operation(summary = "05. 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId(이메일)로 인증번호를 받습니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "이메일 전송 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (loginId 없음)") - }) + @Operation(summary = "05. 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId로 인증번호를 받습니다.") public ResponseEntity sendEmail( @RequestBody(required = false) MemberEmailRequestDto requestDto, Authentication authentication, HttpServletRequest request) { - String loginId = null; - - // 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1) - if (authentication != null && authentication.isAuthenticated() && - !"anonymousUser".equals(authentication.getPrincipal())) { - - // JWT 토큰에서 직접 loginid claim 추출 - try { - String token = extractAccessTokenFromRequest(request); - if (token != null) { - loginId = memberService.extractLoginIdFromToken(token); - if (loginId != null) { - log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId); - } else { - log.warn("JWT 토큰에서 loginId 추출 실패"); - } - } - } catch (Exception e) { - log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage()); - } - } - - // 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2) - if (loginId == null) { - if (requestDto != null && requestDto.getLoginId() != null && !requestDto.getLoginId().isBlank()) { - loginId = requestDto.getLoginId(); - log.info("요청 바디에서 loginId 추출 성공: {}", loginId); - } else { - log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음"); - throw new IllegalArgumentException("인증번호를 전송할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요."); - } - } + String loginId = resolveLoginId(authentication, request, + requestDto != null ? requestDto.getLoginId() : null); - try { - // 서비스 호출 - memberService.sendCodeToEmailByLoginId(loginId); - log.info("이메일 인증번호 전송 성공: {}", loginId); - return ResponseEntity.ok(EmailResponse.success("이메일 전송 성공", loginId)); - - } catch (IllegalArgumentException e) { - log.error("이메일 전송 실패 - 존재하지 않는 회원: {}", loginId); - throw e; - } catch (Exception e) { - log.error("이메일 전송 실패: loginId={}, error={}", loginId, e.getMessage()); - throw new RuntimeException("이메일 전송 중 오류가 발생했습니다."); - } + memberService.sendCodeToEmailByLoginId(loginId); + return ResponseEntity.ok(EmailResponse.success("이메일 전송 성공", loginId)); } @PostMapping("/verifyEmail") - @Operation(summary = "06. 인증번호 검증", description = "이메일로 받은 인증번호를 검증합니다. (비밀번호 재설정용)") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "인증번호 검증 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (인증번호 불일치, loginId 없음)") - }) + @Operation(summary = "06. 인증번호 검증", description = "이메일로 받은 인증번호를 검증합니다.") public ResponseEntity verifyEmail( - @RequestBody @Valid EmailVerifyCodeRequestDto requestDto - ) { + @RequestBody @Valid EmailVerifyCodeRequestDto requestDto) { if (requestDto.getLoginId() == null || requestDto.getLoginId().isBlank()) { - log.error("요청 바디에 loginId가 없음"); throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다."); } - - String loginId = requestDto.getLoginId(); - log.info("이메일 인증번호 검증 요청: {}", loginId); - - try { - // 서비스 호출 - 인증번호 검증 - boolean isValid = memberService.verifyAuthCode(loginId, requestDto.getVerificationCode()); - - if (isValid) { - log.info("이메일 인증번호 검증 성공: {}", loginId); - return ResponseEntity.ok(VerificationResponse.success("인증번호 검증 성공", loginId)); - } else { - log.error("이메일 인증번호 검증 실패 - 잘못된 인증번호: {}", loginId); - throw new IllegalArgumentException("잘못된 인증번호이거나 만료된 인증번호입니다."); - } - - } catch (IllegalArgumentException e) { - log.error("이메일 인증번호 검증 실패: loginId={}, error={}", loginId, e.getMessage()); - throw e; - } catch (Exception e) { - log.error("이메일 인증번호 검증 중 오류 발생: loginId={}, error={}", loginId, e.getMessage()); - throw new RuntimeException("인증번호 검증 중 오류가 발생했습니다."); + boolean isValid = memberService.verifyAuthCode(requestDto.getLoginId(), requestDto.getVerificationCode()); + if (isValid) { + return ResponseEntity.ok(VerificationResponse.success("인증번호 검증 성공", requestDto.getLoginId())); + } else { + throw new IllegalArgumentException("잘못된 인증번호이거나 만료된 인증번호입니다."); } } @PostMapping("/verifyPassword") - @Operation(summary = "07. 비밀번호 검증", description = "로그인된 사용자가 비밀번호를 통해 인증합니다. (비밀번호 재설정용)") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "비밀번호 검증 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (비밀번호 불일치)"), - @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자") - }) + @Operation(summary = "07. 비밀번호 검증", description = "로그인된 사용자가 비밀번호를 통해 인증합니다.") public ResponseEntity verifyPassword( - @RequestBody @Valid PasswordVerifyRequestDto requestDto, - Authentication authentication, - HttpServletRequest request){ - - if (authentication == null || !authentication.isAuthenticated() || - "anonymousUser".equals(authentication.getPrincipal())) { - log.error("비로그인 사용자의 비밀번호 검증 시도"); - throw new IllegalArgumentException("비밀번호 검증은 로그인된 사용자만 가능합니다. 비로그인 사용자는 이메일 인증을 사용하세요."); - } - - String loginId = null; - try { - String token = extractAccessTokenFromRequest(request); - if (token != null) { - loginId = memberService.extractLoginIdFromToken(token); - } - } catch (Exception e) { - log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage()); - } - - if (loginId == null) { - log.error("JWT 토큰에서 loginId 추출 실패"); - throw new IllegalArgumentException("유효하지 않은 토큰입니다."); - } - - log.info("비밀번호 검증 요청: {}", loginId); - - try { - boolean isValid = memberService.verifyPassword(loginId, requestDto.getPassword()); + @RequestBody @Valid PasswordVerifyRequestDto requestDto, + Authentication authentication, + HttpServletRequest request){ - if (isValid) { - log.info("비밀번호 검증 성공: {}", loginId); - return ResponseEntity.ok(VerificationResponse.success("비밀번호 검증 성공", loginId)); - } else { - log.error("비밀번호 검증 실패 - 비밀번호 불일치: {}", loginId); - throw new IllegalArgumentException("잘못된 입력입니다."); - } + String loginId = resolveLoginId(authentication, request, null); + boolean isValid = memberService.verifyPassword(loginId, requestDto.getPassword()); - } catch (IllegalArgumentException e) { - log.error("비밀번호 검증 실패: loginId={}, error={}", loginId, e.getMessage()); - throw e; - } catch (Exception e) { - log.error("비밀번호 검증 중 오류 발생: loginId={}, error={}", loginId, e.getMessage()); - throw new RuntimeException("비밀번호 검증 중 오류가 발생했습니다."); + if (isValid) { + return ResponseEntity.ok(VerificationResponse.success("비밀번호 검증 성공", loginId)); + } else { + throw new IllegalArgumentException("잘못된 입력입니다."); } } @PostMapping("/passwordReset") @Operation(summary = "08. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"), - @ApiResponse(responseCode = "400", description = "인증되지 않았거나 잘못된 요청") - }) public ResponseEntity resetPassword( @RequestBody ResetPasswordRequestDto request, Authentication authentication, HttpServletRequest httpRequest, HttpServletResponse httpResponse) { - // 입력값 검증 + validateResetPasswordRequest(request); + + String loginId = resolveLoginId(authentication, httpRequest, request.getLoginId()); + + memberService.resetPassword(loginId, request.getNewPassword(), request.getSuccess()); + memberService.logout(loginId, httpResponse); + + return ResponseEntity.ok( + PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", loginId) + ); + } + + // -------------------- 공통 유틸 메서드 -------------------- + + private void validateResetPasswordRequest(ResetPasswordRequestDto request) { + if (request == null) { + throw new IllegalArgumentException("요청 바디가 필요합니다."); + } if (request.getNewPassword() == null || request.getNewPassword().isBlank()) { throw new IllegalArgumentException("새 비밀번호를 입력해주세요."); } if (request.getSuccess() == null) { throw new IllegalArgumentException("인증 성공 여부가 필요합니다."); } + } - String loginId = null; - - if (authentication != null && authentication.isAuthenticated() && - !"anonymousUser".equals(authentication.getPrincipal())) { - - // JWT 토큰에서 직접 loginid claim 추출 - try { - String token = extractAccessTokenFromRequest(httpRequest); - if (token != null) { - loginId = memberService.extractLoginIdFromToken(token); - if (loginId != null) { - log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId); + /** + * 인증 정보(authentication) 우선으로 loginId를 찾고, 없으면 fallbackLoginId를 사용한다. + * authentication이 존재하면 Authorization header 또는 accessToken 쿠키에서 추출을 시도한다. + * 실패 시 fallback으로 넘어간다. fallback도 없으면 IllegalArgumentException 발생. + * 이 메서드는 중첩(네스팅)을 줄이고, 로깅을 하나의 위치로 모아 Sonar의 Cognitive Complexity 규칙을 만족하도록 구성함. + */ + private String resolveLoginId(Authentication authentication, HttpServletRequest request, String fallbackLoginId) { + // 1) 인증된 사용자이고, 프린시펄이 anonymousUser가 아닌 경우 토큰에서 loginId 추출 시도 + if (authentication != null && authentication.isAuthenticated()) { + Object principal = authentication.getPrincipal(); + if (principal != null && !ANONYMOUS_USER.equals(principal)) { + try { + String token = extractAccessTokenFromRequest(request); + if (token != null) { + String resolved = memberService.extractLoginIdFromToken(token); + if (resolved != null) { + log.info(LOG_JWT_EXTRACT_INFO, resolved); + return resolved; + } else { + log.warn(LOG_JWT_EXTRACT_FAIL); + } } else { - log.warn("JWT 토큰에서 loginId 추출 실패"); + log.debug("Authorization header / accessToken cookie 없음"); } + } catch (Exception e) { + // 단일 위치에서 로그를 남기고, 내부 오류는 무시하여 fallback 흐름으로 진행 + log.warn(LOG_JWT_EXTRACT_WARN, e.getMessage()); } - } catch (Exception e) { - log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage()); } } - // 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2) - if (loginId == null) { - if (request.getLoginId() != null && !request.getLoginId().isBlank()) { - loginId = request.getLoginId(); - log.info("요청 바디에서 loginId 추출 성공: {}", loginId); - } else { - log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음"); - throw new IllegalArgumentException("비밀번호를 재설정할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요."); - } + // 2) fallbackLoginId 검증 및 반환 + if (fallbackLoginId != null && !fallbackLoginId.isBlank()) { + return fallbackLoginId; } - log.info("비밀번호 재설정 요청: email={}", loginId); - - memberService.resetPassword(loginId, request.getNewPassword(), request.getSuccess()); - - // 비밀번호 재설정 성공 시 로그아웃 처리 - memberService.logout(loginId, httpResponse); - - log.info("비밀번호 재설정 및 로그아웃 성공: email={}", loginId); - return ResponseEntity.ok(PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", loginId)); + // 3) 찾지 못했으면 예외 + throw new IllegalArgumentException(ERR_MSG_LOGIN_ID_REQUIRED); } - /** - * HTTP 쿠키에서 리프레시 토큰을 추출합니다. - * @param request HTTP 요청 객체 - * @return 리프레시 토큰 값 또는 null - */ - private String extractRefreshTokenFromCookies(HttpServletRequest request) { - if (request.getCookies() != null) { - for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { - if ("refreshToken".equals(cookie.getName())) { - return cookie.getValue(); - } - } - } - return null; - } - - /** - * HTTP 요청에서 액세스 토큰을 추출합니다. - * Authorization 헤더 또는 쿠키에서 토큰을 확인합니다. - * @param request HTTP 요청 객체 - * @return 액세스 토큰 값 또는 null - */ private String extractAccessTokenFromRequest(HttpServletRequest request) { - // 1. Authorization 헤더에서 추출 시도 String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } - - // 2. 쿠키에서 추출 시도 if (request.getCookies() != null) { for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { if ("accessToken".equals(cookie.getName())) { @@ -393,4 +307,4 @@ private String extractAccessTokenFromRequest(HttpServletRequest request) { } return null; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java index 8d33614a..544fe3cc 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java @@ -1,19 +1,14 @@ package com.ai.lawyer.domain.member.dto; -import lombok.AllArgsConstructor; -import lombok.Getter; - import java.time.LocalDateTime; -@Getter -@AllArgsConstructor -public class MemberErrorResponse { - private final String message; - private final int status; - private final String error; - private final LocalDateTime timestamp; - +public record MemberErrorResponse( + String message, + int status, + String error, + LocalDateTime timestamp +) { public static MemberErrorResponse of(String message, int status, String error) { return new MemberErrorResponse(message, status, error, LocalDateTime.now()); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberResponse.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberResponse.java index 8dd80c8e..51f1947a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberResponse.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberResponse.java @@ -1,6 +1,8 @@ package com.ai.lawyer.domain.member.dto; import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.MemberAdapter; +import com.ai.lawyer.domain.member.entity.OAuth2Member; import lombok.*; import java.time.LocalDateTime; @@ -14,6 +16,7 @@ public class MemberResponse { private Long memberId; private String loginId; + private String email; // 이메일 추가 private Integer age; private Member.Gender gender; private Member.Role role; @@ -25,6 +28,7 @@ public static MemberResponse from(Member member) { return MemberResponse.builder() .memberId(member.getMemberId()) .loginId(member.getLoginId()) + .email(member.getLoginId()) // 로컬 회원은 loginId가 이메일 .age(member.getAge()) .gender(member.getGender()) .role(member.getRole()) @@ -33,4 +37,24 @@ public static MemberResponse from(Member member) { .updatedAt(member.getUpdatedAt()) .build(); } -} \ No newline at end of file + + public static MemberResponse from(MemberAdapter memberAdapter) { + return switch (memberAdapter) { + case null -> throw new IllegalArgumentException("MemberAdapter cannot be null"); + case Member member -> from(member); + case OAuth2Member oauth2Member -> MemberResponse.builder() + .memberId(oauth2Member.getMemberId()) + .loginId(oauth2Member.getLoginId()) + .email(oauth2Member.getEmail()) // OAuth2Member의 email 컬럼 + .age(oauth2Member.getAge()) + .gender(oauth2Member.getGender()) + .role(oauth2Member.getRole()) + .name(oauth2Member.getName()) + .createdAt(oauth2Member.getCreatedAt()) + .updatedAt(oauth2Member.getUpdatedAt()) + .build(); + default -> + throw new IllegalArgumentException("Unsupported member type: " + memberAdapter.getClass().getName()); + }; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/OAuth2LoginTestRequest.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/OAuth2LoginTestRequest.java new file mode 100644 index 00000000..1493e3ea --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/OAuth2LoginTestRequest.java @@ -0,0 +1,43 @@ +package com.ai.lawyer.domain.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "OAuth2 로그인 테스트 요청 (개발/테스트용)") +public class OAuth2LoginTestRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이어야 합니다") + @Schema(description = "사용자 이메일", example = "test@kakao.com") + private String email; + + @NotBlank(message = "이름은 필수입니다") + @Schema(description = "사용자 이름", example = "홍길동") + private String name; + + @NotNull(message = "나이는 필수입니다") + @Schema(description = "사용자 나이", example = "25") + private Integer age; + + @NotBlank(message = "성별은 필수입니다") + @Schema(description = "사용자 성별 (MALE/FEMALE/OTHER)", example = "MALE") + private String gender; + + @NotBlank(message = "Provider는 필수입니다") + @Schema(description = "OAuth Provider (KAKAO/NAVER)", example = "KAKAO") + private String provider; + + @NotBlank(message = "Provider ID는 필수입니다") + @Schema(description = "OAuth Provider의 사용자 ID", example = "123456789") + private String providerId; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java index 630aa4f3..54c113c1 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java @@ -11,7 +11,7 @@ @Entity @Table(name = "member", indexes = { - @Index(name = "idx_member_loginid", columnList = "loginid") + @Index(name = "idx_member_login_id", columnList = "login_id") }) @Getter @Setter @@ -20,14 +20,14 @@ @Builder @ToString(exclude = "password") @EqualsAndHashCode(of = "memberId") -public class Member { +public class Member implements MemberAdapter { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "member_id", nullable = false) private Long memberId; - @Column(name = "loginid", nullable = false, unique = true, length = 100) + @Column(name = "login_id", nullable = false, unique = true, length = 100) @Email(message = "올바른 이메일 형식이 아닙니다") @NotBlank(message = "이메일(로그인 ID)은 필수입니다") private String loginId; diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/MemberAdapter.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/MemberAdapter.java new file mode 100644 index 00000000..8af08e0a --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/MemberAdapter.java @@ -0,0 +1,42 @@ +package com.ai.lawyer.domain.member.entity; + +/** + * Member와 OAuth2Member를 통합해서 처리하기 위한 어댑터 인터페이스 + * TokenProvider, JwtAuthenticationFilter 등에서 동일한 방식으로 처리 가능 + */ +public interface MemberAdapter { + Long getMemberId(); + String getLoginId(); + String getName(); + Integer getAge(); + Member.Gender getGender(); + Member.Role getRole(); + + /** + * 이메일 반환 + * - Member: loginId 반환 (loginId가 이메일) + * - OAuth2Member: email 컬럼 반환 + */ + default String getEmail() { + if (isLocalMember()) { + return getLoginId(); // 로컬 회원은 loginId가 이메일 + } else if (isOAuth2Member()) { + return this.getEmail(); + } + return null; + } + + /** + * 로컬 회원인지 확인 + */ + default boolean isLocalMember() { + return this instanceof Member; + } + + /** + * OAuth2 회원인지 확인 + */ + default boolean isOAuth2Member() { + return this instanceof OAuth2Member; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java new file mode 100644 index 00000000..2217d3ae --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/entity/OAuth2Member.java @@ -0,0 +1,83 @@ +package com.ai.lawyer.domain.member.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "oauth2_member", + indexes = { + @Index(name = "idx_oauth2_member_login_id", columnList = "login_id"), + @Index(name = "idx_oauth2_member_provider", columnList = "provider, provider_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString +@EqualsAndHashCode() +public class OAuth2Member implements MemberAdapter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "login_id", nullable = false, unique = true, length = 100) + @Email(message = "올바른 이메일 형식이 아닙니다") + @NotBlank(message = "이메일(로그인 ID)은 필수입니다") + private String loginId; + + @Column(name = "email", nullable = false, length = 100) + @Email(message = "올바른 이메일 형식이 아닙니다") + @NotBlank(message = "이메일은 필수입니다") + private String email; + + @Column(name = "age", nullable = false) + @NotNull(message = "나이는 필수입니다") + @Min(value = 14, message = "최소 14세 이상이어야 합니다") + private Integer age; + + @Enumerated(EnumType.STRING) + @Column(name = "gender", nullable = false, length = 10) + @NotNull(message = "성별은 필수입니다") + private Member.Gender gender; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false, length = 20) + @Builder.Default + private Member.Role role = Member.Role.USER; + + @Column(name = "name", nullable = false, length = 20) + @NotBlank(message = "이름은 필수입니다") + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 20) + @NotNull(message = "OAuth Provider는 필수입니다") + private Provider provider; + + @Column(name = "provider_id", nullable = false, length = 100) + @NotBlank(message = "Provider ID는 필수입니다") + private String providerId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Getter + public enum Provider { + KAKAO("카카오"), NAVER("네이버"); + private final String description; + Provider(String description) { this.description = description; } + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/OAuth2MemberRepository.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/OAuth2MemberRepository.java new file mode 100644 index 00000000..c7250ec1 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/OAuth2MemberRepository.java @@ -0,0 +1,16 @@ +package com.ai.lawyer.domain.member.repositories; + +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface OAuth2MemberRepository extends JpaRepository { + + /** + * loginId(email)로 OAuth2 회원 조회 + */ + Optional findByLoginId(String loginId); +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index f0489033..ca450864 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -2,29 +2,60 @@ import com.ai.lawyer.domain.member.dto.*; import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.OAuth2Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.jwt.CookieUtil; import com.ai.lawyer.global.email.service.EmailService; import com.ai.lawyer.global.email.service.EmailAuthService; -import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.servlet.http.HttpServletResponse; +@Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) public class MemberService { private final MemberRepository memberRepository; + private OAuth2MemberRepository oauth2MemberRepository; private final PasswordEncoder passwordEncoder; private final TokenProvider tokenProvider; private final CookieUtil cookieUtil; private final EmailService emailService; private final EmailAuthService emailAuthService; + public MemberService( + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + TokenProvider tokenProvider, + CookieUtil cookieUtil, + EmailService emailService, + EmailAuthService emailAuthService) { + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.tokenProvider = tokenProvider; + this.cookieUtil = cookieUtil; + this.emailService = emailService; + this.emailAuthService = emailAuthService; + } + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setOauth2MemberRepository(OAuth2MemberRepository oauth2MemberRepository) { + this.oauth2MemberRepository = oauth2MemberRepository; + } + + // 에러 메시지 상수 + private static final String ERR_DUPLICATE_EMAIL = "이미 존재하는 이메일입니다."; + private static final String ERR_MEMBER_NOT_FOUND = "존재하지 않는 회원입니다."; + private static final String ERR_PASSWORD_MISMATCH = "비밀번호가 일치하지 않습니다."; + private static final String ERR_INVALID_REFRESH_TOKEN = "유효하지 않은 리프레시 토큰입니다."; + private static final String ERR_MEMBER_NOT_FOUND_BY_LOGIN_ID = "해당 로그인 ID의 회원이 없습니다."; + private static final String ERR_EMAIL_VERIFICATION_REQUIRED = "이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."; + @Transactional public MemberResponse signup(MemberSignupRequest request, HttpServletResponse response) { validateDuplicateLoginId(request.getLoginId()); @@ -49,10 +80,10 @@ public MemberResponse signup(MemberSignupRequest request, HttpServletResponse re public MemberResponse login(MemberLoginRequest request, HttpServletResponse response) { Member member = memberRepository.findByLoginId(request.getLoginId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND)); if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + throw new IllegalArgumentException(ERR_PASSWORD_MISMATCH); } String accessToken = tokenProvider.generateAccessToken(member); @@ -71,24 +102,52 @@ public void logout(String loginId, HttpServletResponse response) { } public MemberResponse refreshToken(String refreshToken, HttpServletResponse response) { + log.debug("토큰 재발급 시작: refreshToken={}", refreshToken.substring(0, Math.min(10, refreshToken.length())) + "..."); + + // 1. 리프레시 토큰으로 loginId 찾기 String loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); if (loginId == null) { - throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); + log.warn("Redis에서 리프레시 토큰을 찾을 수 없습니다."); + throw new IllegalArgumentException(ERR_INVALID_REFRESH_TOKEN); } + log.debug("리프레시 토큰으로 찾은 loginId: {}", loginId); + // 2. 리프레시 토큰 검증 if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { - throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); + log.warn("리프레시 토큰 검증 실패: loginId={}", loginId); + throw new IllegalArgumentException(ERR_INVALID_REFRESH_TOKEN); } - Member member = memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + // 3. Member 또는 OAuth2Member 조회 + com.ai.lawyer.domain.member.entity.MemberAdapter member = memberRepository.findByLoginId(loginId).orElse(null); + if (member != null) { + log.info("로컬 회원 찾음: loginId={}, memberId={}", loginId, member.getMemberId()); + } else if (oauth2MemberRepository != null) { + member = oauth2MemberRepository.findByLoginId(loginId).orElse(null); + if (member != null) { + log.info("OAuth2 회원 찾음: loginId={}, memberId={}", loginId, member.getMemberId()); + } + } + + if (member == null) { + log.error("회원을 찾을 수 없습니다: loginId={}", loginId); + throw new IllegalArgumentException(ERR_MEMBER_NOT_FOUND); + } + + // 4. 기존 토큰 삭제 tokenProvider.deleteAllTokens(loginId); + log.debug("기존 토큰 삭제 완료: loginId={}", loginId); + // 5. 새 토큰 생성 String newAccessToken = tokenProvider.generateAccessToken(member); String newRefreshToken = tokenProvider.generateRefreshToken(member); + log.debug("새 토큰 생성 완료: loginId={}", loginId); + // 6. 쿠키 설정 cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); + log.info("토큰 재발급 성공: loginId={}, memberId={}, memberType={}", + loginId, member.getMemberId(), member.getClass().getSimpleName()); return MemberResponse.from(member); } @@ -96,35 +155,43 @@ public MemberResponse refreshToken(String refreshToken, HttpServletResponse resp @Transactional public void withdraw(Long memberId) { Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND)); memberRepository.delete(member); } public MemberResponse getMemberById(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + // Member 또는 OAuth2Member 조회 + com.ai.lawyer.domain.member.entity.MemberAdapter member = memberRepository.findById(memberId).orElse(null); + + if (member == null && oauth2MemberRepository != null) { + member = oauth2MemberRepository.findById(memberId).orElse(null); + } + + if (member == null) { + throw new IllegalArgumentException(ERR_MEMBER_NOT_FOUND); + } return MemberResponse.from(member); } public void sendCodeToEmailByLoginId(String loginId) { Member member = memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new IllegalArgumentException("해당 로그인 ID의 회원이 없습니다.")); + .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND_BY_LOGIN_ID)); String email = member.getLoginId(); emailService.sendVerificationCode(email, loginId); } public boolean verifyAuthCode(String loginId, String verificationCode) { memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND)); return emailAuthService.verifyAuthCode(loginId, verificationCode); } public boolean verifyPassword(String loginId, String password) { Member member = memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND)); boolean isValid = passwordEncoder.matches(password, member.getPassword()); @@ -139,17 +206,17 @@ public boolean verifyPassword(String loginId, String password) { @Transactional public void resetPassword(String loginId, String newPassword, Boolean success) { Member member = memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND)); boolean clientSuccess = Boolean.TRUE.equals(success); if (!clientSuccess) { - throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); + throw new IllegalArgumentException(ERR_EMAIL_VERIFICATION_REQUIRED); } boolean redisVerified = emailAuthService.isEmailVerified(loginId); if (!redisVerified) { - throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); + throw new IllegalArgumentException(ERR_EMAIL_VERIFICATION_REQUIRED); } String encodedPassword = passwordEncoder.encode(newPassword); @@ -165,9 +232,41 @@ public String extractLoginIdFromToken(String token) { return tokenProvider.getLoginIdFromToken(token); } + @Transactional + public MemberResponse oauth2LoginTest(OAuth2LoginTestRequest request, HttpServletResponse response) { + if (oauth2MemberRepository == null) { + throw new IllegalStateException("OAuth2 기능이 비활성화되어 있습니다."); + } + + // 기존 OAuth2 회원 조회 + OAuth2Member oauth2Member = oauth2MemberRepository.findByLoginId(request.getEmail()).orElse(null); + + if (oauth2Member == null) { + // 신규 OAuth2 회원 생성 + oauth2Member = OAuth2Member.builder() + .loginId(request.getEmail()) // loginId와 email을 동일하게 설정 + .email(request.getEmail()) // email 컬럼에도 저장 + .name(request.getName()) + .age(request.getAge()) + .gender(Member.Gender.valueOf(request.getGender())) + .provider(OAuth2Member.Provider.valueOf(request.getProvider())) + .providerId(request.getProviderId()) + .role(Member.Role.USER) + .build(); + oauth2Member = oauth2MemberRepository.save(oauth2Member); + } + + // JWT 토큰 생성 및 쿠키 설정 + String accessToken = tokenProvider.generateAccessToken(oauth2Member); + String refreshToken = tokenProvider.generateRefreshToken(oauth2Member); + cookieUtil.setTokenCookies(response, accessToken, refreshToken); + + return MemberResponse.from(oauth2Member); + } + private void validateDuplicateLoginId(String loginId) { if (memberRepository.existsByLoginId(loginId)) { - throw new IllegalArgumentException("이미 존재하는 이메일입니다."); + throw new IllegalArgumentException(ERR_DUPLICATE_EMAIL); } } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java index 614e01d6..8017fed8 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java @@ -14,4 +14,4 @@ public interface PollRepository extends JpaRepository { @Query("SELECT p FROM Poll p LEFT JOIN PollVote v ON p = v.poll GROUP BY p ORDER BY COUNT(v) DESC") List findTopPollsByVoteCount(Pageable pageable); -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index 712f68c1..1fb4afc9 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -24,30 +24,19 @@ public interface PollVoteRepository extends JpaRepository { @Query("SELECT v.pollOptions.pollItemsId, v.member.gender, v.member.age, COUNT(v.pollVoteId) FROM PollVote v WHERE v.pollOptions.pollItemsId IN :pollOptionIds GROUP BY v.pollOptions.pollItemsId, v.member.gender, v.member.age") java.util.List countStaticsByPollOptionIds(@Param("pollOptionIds") java.util.List pollOptionIds); - boolean existsByPoll_PollIdAndMember_MemberId(Long pollId, Long memberId); - - @Query(value = "SELECT po.option, m.gender, COUNT(*) FROM poll_vote pv JOIN poll_options po ON pv.poll_items_id = po.poll_items_id JOIN member m ON pv.member_id = m.member_id WHERE po.poll_id = :pollId GROUP BY po.option, m.gender", nativeQuery = true) - List getGenderOptionStatics(@Param("pollId") Long pollId); - - @Query(value = "SELECT CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + - "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + - "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' " + - "END AS ageGroup, m.gender, COUNT(*) FROM poll_vote pv JOIN member m ON pv.member_id = m.member_id JOIN poll_options po ON pv.poll_items_id = po.poll_items_id WHERE po.poll_id = :pollId GROUP BY ageGroup, m.gender", nativeQuery = true) - List getAgeGenderStatics(@Param("pollId") Long pollId); - @Query("SELECT o.option, " + - "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + - "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + - "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END, " + - "COUNT(v) " + - "FROM PollVote v JOIN v.pollOptions o JOIN v.member m " + - "WHERE o.poll.pollId = :pollId " + - "GROUP BY o.option, " + - "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + - "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + - "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END") + "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + + "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + + "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END, " + + "COUNT(v) " + + "FROM PollVote v JOIN v.pollOptions o JOIN v.member m " + + "WHERE o.poll.pollId = :pollId " + + "GROUP BY o.option, " + + "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + + "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + + "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END") List getOptionAgeStatics(@Param("pollId") Long pollId); @Query("SELECT o.option, m.gender, COUNT(v) FROM PollVote v JOIN v.pollOptions o JOIN v.member m WHERE o.poll.pollId = :pollId GROUP BY o.option, m.gender") List getOptionGenderStatics(@Param("pollId") Long pollId); -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index feb876b0..926ae075 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -102,11 +102,11 @@ public List getPollsByStatus(PollDto.PollStatus status) { autoCloseIfNeeded(poll); } List pollDtos = polls.stream() - .filter(p -> p.getStatus().name().equals(status.name())) - .map(p -> status == PollDto.PollStatus.CLOSED - ? getPollWithStatistics(p.getPollId()) - : convertToDto(p)) - .toList(); + .filter(p -> p.getStatus().name().equals(status.name())) + .map(p -> status == PollDto.PollStatus.CLOSED + ? getPollWithStatistics(p.getPollId()) + : convertToDto(p)) + .toList(); return pollDtos; } @@ -170,20 +170,20 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { if (opt == null) continue; Long pollItemsId = opt.getPollItemsId(); PollAgeStaticsDto.AgeGroupCountDto dto = PollAgeStaticsDto.AgeGroupCountDto.builder() - .option(option) - .ageGroup(arr[1] != null ? arr[1].toString() : null) - .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) - .build(); + .option(option) + .ageGroup(arr[1] != null ? arr[1].toString() : null) + .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .build(); ageGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); } java.util.List optionAgeStatics = new java.util.ArrayList<>(); for (int i = 0; i < options.size(); i++) { PollOptions opt = options.get(i); optionAgeStatics.add(PollAgeStaticsDto.builder() - .pollItemsId(opt.getPollItemsId()) - .pollOptionIndex(i + 1) - .ageGroupCounts(ageGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) - .build()); + .pollItemsId(opt.getPollItemsId()) + .pollOptionIndex(i + 1) + .ageGroupCounts(ageGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) + .build()); } // gender 통계 그룹핑 List optionGenderRaw = pollVoteRepository.getOptionGenderStatics(pollId); @@ -194,27 +194,27 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { if (opt == null) continue; Long pollItemsId = opt.getPollItemsId(); PollGenderStaticsDto.GenderCountDto dto = PollGenderStaticsDto.GenderCountDto.builder() - .option(option) - .gender(arr[1] != null ? arr[1].toString() : null) - .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) - .build(); + .option(option) + .gender(arr[1] != null ? arr[1].toString() : null) + .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .build(); genderGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); } java.util.List optionGenderStatics = new java.util.ArrayList<>(); for (int i = 0; i < options.size(); i++) { PollOptions opt = options.get(i); optionGenderStatics.add(PollGenderStaticsDto.builder() - .pollItemsId(opt.getPollItemsId()) - .pollOptionIndex(i + 1) - .genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) - .build()); + .pollItemsId(opt.getPollItemsId()) + .pollOptionIndex(i + 1) + .genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) + .build()); } return PollStaticsResponseDto.builder() - .postId(postId) - .pollId(pollId) - .optionAgeStatics(optionAgeStatics) - .optionGenderStatics(optionGenderStatics) - .build(); + .postId(postId) + .pollId(pollId) + .optionAgeStatics(optionAgeStatics) + .optionGenderStatics(optionGenderStatics) + .build(); } // 최대 7일 동안 투표 가능 (초기 요구사항) @@ -252,15 +252,15 @@ public PollDto getTopPollByStatus(PollDto.PollStatus status) { if (result.isEmpty()) { // 종료된 투표가 없으면 빈 PollDto 반환 return PollDto.builder() - .pollId(null) - .postId(null) - .voteTitle(null) - .status(status) - .createdAt(null) - .closedAt(null) - .pollOptions(java.util.Collections.emptyList()) - .totalVoteCount(0L) - .build(); + .pollId(null) + .postId(null) + .voteTitle(null) + .status(status) + .createdAt(null) + .closedAt(null) + .pollOptions(java.util.Collections.emptyList()) + .totalVoteCount(0L) + .build(); } Long pollId = (Long) result.get(0)[0]; return getPoll(pollId); @@ -270,13 +270,13 @@ public PollDto getTopPollByStatus(PollDto.PollStatus status) { public List getTopNPollsByStatus(PollDto.PollStatus status, int n) { Pageable pageable = org.springframework.data.domain.PageRequest.of(0, n); List result = pollVoteRepository.findTopNPollByStatus( - com.ai.lawyer.domain.poll.entity.Poll.PollStatus.valueOf(status.name()), pageable); + com.ai.lawyer.domain.poll.entity.Poll.PollStatus.valueOf(status.name()), pageable); List pollDtos = new java.util.ArrayList<>(); for (Object[] row : result) { Long pollId = (Long) row[0]; pollDtos.add(status == PollDto.PollStatus.CLOSED - ? getPollWithStatistics(pollId) - : getPoll(pollId)); + ? getPollWithStatistics(pollId) + : getPoll(pollId)); } return pollDtos; } @@ -323,17 +323,17 @@ public PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto) { if (optionDto.getPollItemsId() != null) { // update PollOptions option = existingOptions.stream() - .filter(o -> o.getPollItemsId().equals(optionDto.getPollItemsId())) - .findFirst().orElse(null); + .filter(o -> o.getPollItemsId().equals(optionDto.getPollItemsId())) + .findFirst().orElse(null); if (option != null) { option.setOption(optionDto.getContent()); pollOptionsRepository.save(option); } } else { PollOptions newOption = PollOptions.builder() - .poll(poll) - .option(optionDto.getContent()) - .build(); + .poll(poll) + .option(optionDto.getContent()) + .build(); pollOptionsRepository.save(newOption); } } @@ -381,17 +381,17 @@ public void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto) { for (var optionDto : pollUpdateDto.getPollOptions()) { if (optionDto.getPollItemsId() != null) { PollOptions option = existingOptions.stream() - .filter(o -> o.getPollItemsId().equals(optionDto.getPollItemsId())) - .findFirst().orElse(null); + .filter(o -> o.getPollItemsId().equals(optionDto.getPollItemsId())) + .findFirst().orElse(null); if (option != null) { option.setOption(optionDto.getContent()); pollOptionsRepository.save(option); } } else { PollOptions newOption = PollOptions.builder() - .poll(poll) - .option(optionDto.getContent()) - .build(); + .poll(poll) + .option(optionDto.getContent()) + .build(); pollOptionsRepository.save(newOption); } } @@ -428,25 +428,25 @@ public PollDto getPollWithStatistics(Long pollId) { PollOptions option = options.get(i); Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); List statics = staticsRaw.stream() - .filter(arr -> ((Long)arr[0]).equals(option.getPollItemsId())) - .map(arr -> { - String gender = arr[1] != null ? arr[1].toString() : null; - Integer age = arr[2] != null ? ((Number)arr[2]).intValue() : null; - String ageGroup = getAgeGroup(age); - return PollStaticsDto.builder() - .gender(gender) - .ageGroup(ageGroup) - .voteCount((Long)arr[3]) - .build(); - }) - .toList(); + .filter(arr -> ((Long)arr[0]).equals(option.getPollItemsId())) + .map(arr -> { + String gender = arr[1] != null ? arr[1].toString() : null; + Integer age = arr[2] != null ? ((Number)arr[2]).intValue() : null; + String ageGroup = getAgeGroup(age); + return PollStaticsDto.builder() + .gender(gender) + .ageGroup(ageGroup) + .voteCount((Long)arr[3]) + .build(); + }) + .toList(); optionDtos.add(PollOptionDto.builder() - .pollItemsId(option.getPollItemsId()) - .content(option.getOption()) - .voteCount(voteCount) - .statics(statics) - .pollOptionIndex(i + 1) - .build()); + .pollItemsId(option.getPollItemsId()) + .content(option.getOption()) + .voteCount(voteCount) + .statics(statics) + .pollOptionIndex(i + 1) + .build()); } } else { optionDtos = new ArrayList<>(); @@ -454,24 +454,24 @@ public PollDto getPollWithStatistics(Long pollId) { PollOptions option = options.get(i); Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); optionDtos.add(PollOptionDto.builder() - .pollItemsId(option.getPollItemsId()) - .content(option.getOption()) - .voteCount(voteCount) - .statics(null) - .pollOptionIndex(i + 1) - .build()); + .pollItemsId(option.getPollItemsId()) + .content(option.getOption()) + .voteCount(voteCount) + .statics(null) + .pollOptionIndex(i + 1) + .build()); } } return PollDto.builder() - .pollId(poll.getPollId()) - .postId(poll.getPost() != null ? poll.getPost().getPostId() : null) - .voteTitle(poll.getVoteTitle()) - .status(PollDto.PollStatus.valueOf(poll.getStatus().name())) - .createdAt(poll.getCreatedAt()) - .closedAt(poll.getClosedAt()) - .pollOptions(optionDtos) - .totalVoteCount(totalVoteCount) - .build(); + .pollId(poll.getPollId()) + .postId(poll.getPost() != null ? poll.getPost().getPostId() : null) + .voteTitle(poll.getVoteTitle()) + .status(PollDto.PollStatus.valueOf(poll.getStatus().name())) + .createdAt(poll.getCreatedAt()) + .closedAt(poll.getClosedAt()) + .pollOptions(optionDtos) + .totalVoteCount(totalVoteCount) + .build(); } private PollDto convertToDto(Poll poll) { @@ -482,12 +482,12 @@ private PollDto convertToDto(Poll poll) { PollOptions option = options.get(i); Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); optionDtos.add(PollOptionDto.builder() - .pollItemsId(option.getPollItemsId()) - .content(option.getOption()) - .voteCount(voteCount) - .statics(null) - .pollOptionIndex(i + 1) - .build()); + .pollItemsId(option.getPollItemsId()) + .content(option.getOption()) + .voteCount(voteCount) + .statics(null) + .pollOptionIndex(i + 1) + .build()); } LocalDateTime expectedCloseAt = poll.getReservedCloseAt() != null ? poll.getReservedCloseAt() : poll.getCreatedAt().plusDays(7); return PollDto.builder() @@ -568,4 +568,4 @@ public void validatePollCreate(PollForPostDto dto) { public void validatePollCreate(PollCreateDto dto) { validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java index fc8a74e6..d8df8a58 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java @@ -1,12 +1,7 @@ package com.ai.lawyer.domain.post.controller; -import com.ai.lawyer.domain.post.dto.PostDto; -import com.ai.lawyer.domain.post.dto.PostDetailDto; -import com.ai.lawyer.domain.post.dto.PostRequestDto; -import com.ai.lawyer.domain.post.dto.PostUpdateDto; -import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; +import com.ai.lawyer.domain.post.dto.*; import com.ai.lawyer.domain.post.service.PostService; -import com.ai.lawyer.domain.post.dto.PostSimpleDto; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.jwt.TokenProvider; @@ -16,31 +11,30 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import java.util.List; @Tag(name = "Post API", description = "게시글 관련 API") @RestController @RequestMapping("/api/posts") +@RequiredArgsConstructor public class PostController { private final PostService postService; private final MemberRepository memberRepository; private final TokenProvider tokenProvider; - @Autowired - public PostController(PostService postService, MemberRepository memberRepository, TokenProvider tokenProvider) { - this.postService = postService; - this.memberRepository = memberRepository; - this.tokenProvider = tokenProvider; - } - @Operation(summary = "게시글 등록") @PostMapping public ResponseEntity> createPost(@RequestBody PostRequestDto postRequestDto) { @@ -174,4 +168,19 @@ public ResponseEntity> createPostWithPoll(@RequestBod PostDetailDto result = postService.createPostWithPoll(dto, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "게시글+투표 등록 완료", result)); } + + @Operation(summary = "게시글 페이징 조회") + @GetMapping("/paged") + public ResponseEntity> getPostsPaged( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Page posts = postService.getPostsPaged(pageable); + if (posts == null) { + posts = new org.springframework.data.domain.PageImpl<>(java.util.Collections.emptyList(), pageable, 0); + } + PostPageDTO response = new PostPageDTO(posts); + return ResponseEntity.ok(new ApiResponse<>(200, "페이징 게시글 조회 성공", response)); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostPageDTO.java b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostPageDTO.java new file mode 100644 index 00000000..5148d7b6 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostPageDTO.java @@ -0,0 +1,27 @@ +package com.ai.lawyer.domain.post.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class PostPageDTO { + private List content; + private int page; + private int size; + private int totalPages; + private long totalElements; + + public PostPageDTO(Page page) { + this.content = page.getContent(); + this.page = page.getNumber(); + this.size = page.getSize(); + this.totalPages = page.getTotalPages(); + this.totalElements = page.getTotalElements(); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index 67d88f05..f76ae59a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java @@ -7,6 +7,8 @@ import com.ai.lawyer.domain.post.dto.PostUpdateDto; import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.dto.PostSimpleDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; @@ -28,4 +30,7 @@ public interface PostService { // ===== 본인 게시글 관련 ===== PostDto getMyPostById(Long postId, Long requesterMemberId); List getMyPosts(Long requesterMemberId); + + // ===== 페이징 관련 ===== + Page getPostsPaged(Pageable pageable); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index a2fe1022..01ab378d 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -19,6 +19,8 @@ import com.ai.lawyer.domain.poll.entity.PollOptions; import com.ai.lawyer.domain.poll.repository.PollVoteRepository; import com.ai.lawyer.domain.poll.service.PollService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -247,6 +249,11 @@ public List getAllSimplePosts() { .collect(Collectors.toList()); } + @Override + public Page getPostsPaged(Pageable pageable) { + return postRepository.findAll(pageable).map(this::convertToDto); + } + private PostDto convertToDto(Post entity) { Long memberId = null; if (entity.getMember() != null) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java index 3e370f82..12ebf644 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/dto/PrecedentSearchRequestDto.java @@ -3,12 +3,20 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.time.LocalDate; + @Data public class PrecedentSearchRequestDto { @Schema(description = "검색 키워드", example = "절도") private String keyword; // 검색 키워드 + @Schema(description = "선고일자 시작", example = "2000-01-01") + private LocalDate sentencingDateStart; // 선고일자 시작 + + @Schema(description = "선고일자 종료", example = "2024-12-31") + private LocalDate sentencingDateEnd; // 선고일자 종료 + @Schema(description = "페이지 번호 (0부터 시작)", example = "0") private int pageNumber; // 페이지 번호 diff --git a/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java index ee4076b3..ac3b7f88 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/precedent/repository/PrecedentRepositoryImpl.java @@ -38,6 +38,19 @@ public Page searchPrecedentsByKeyword(PrecedentSearchRe .or(precedent.getCaseNumber().like(pattern)); } + // 선고일자 범위 조건 + if (requestDto.getSentencingDateStart() != null && + requestDto.getSentencingDateEnd() != null) { + builder.and(precedent.getSentencingDate().between( + requestDto.getSentencingDateStart(), + requestDto.getSentencingDateEnd())); + } else if (requestDto.getSentencingDateStart() != null) { + builder.and(precedent.getSentencingDate().goe(requestDto.getSentencingDateStart())); + } else if (requestDto.getSentencingDateEnd() != null) { + builder.and(precedent.getSentencingDate().loe(requestDto.getSentencingDateEnd())); + } + + // 페이징 및 정렬 설정 Pageable pageable = PageRequest.of( requestDto.getPageNumber(), diff --git a/backend/src/main/java/com/ai/lawyer/global/config/EmbeddedRedisConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/EmbeddedRedisConfig.java index 252314d7..d24d7f86 100644 --- a/backend/src/main/java/com/ai/lawyer/global/config/EmbeddedRedisConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/config/EmbeddedRedisConfig.java @@ -52,7 +52,7 @@ public void startRedis() { try { redisServer = RedisServer.builder() .port(redisPort) - .setting("maxmemory 128M") + .setting("max_memory 128M") .build(); if (!redisServer.isActive()) { diff --git a/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java b/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java index d4945d13..36874c77 100644 --- a/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java +++ b/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java @@ -13,12 +13,13 @@ public class EmailAuthService { private final RedisTemplate redisTemplate; private static final long EXPIRATION_MINUTES = 5; // 인증번호 유효시간 (5분) + private static final Random RANDOM = new Random(); // Random 인스턴스 재사용 /** * 인증번호 생성 후 Redis에 저장 */ public String generateAndSaveAuthCode(String loginId) { - String code = String.format("%06d", new Random().nextInt(999999)); // 6자리 랜덤 숫자 + String code = String.format("%06d", RANDOM.nextInt(999999)); // 6자리 랜덤 숫자 redisTemplate.opsForValue().set(buildKey(loginId), code, EXPIRATION_MINUTES, TimeUnit.MINUTES); return code; } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java index 10d7a642..bb31c6a4 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java @@ -8,10 +8,21 @@ @Component public class CookieUtil { + // 쿠키 이름 상수 private static final String ACCESS_TOKEN_NAME = "accessToken"; private static final String REFRESH_TOKEN_NAME = "refreshToken"; - private static final int ACCESS_TOKEN_EXPIRE_TIME = 5 * 60; // 5분 - private static final int REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 + + // 쿠키 만료 시간 상수 (초 단위) + private static final int MINUTES_PER_HOUR = 60; + private static final int HOURS_PER_DAY = 24; + private static final int ACCESS_TOKEN_EXPIRE_TIME = 5 * MINUTES_PER_HOUR; // 5분 + private static final int REFRESH_TOKEN_EXPIRE_TIME = 7 * HOURS_PER_DAY * MINUTES_PER_HOUR * 60; // 7일 + + // 쿠키 보안 설정 상수 + private static final boolean HTTP_ONLY = true; + private static final boolean SECURE_IN_PRODUCTION = false; // 운영환경에서는 true로 변경 (HTTPS) + private static final String COOKIE_PATH = "/"; + private static final int COOKIE_EXPIRE_IMMEDIATELY = 0; public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { setAccessTokenCookie(response, accessToken); @@ -19,20 +30,12 @@ public void setTokenCookies(HttpServletResponse response, String accessToken, St } public void setAccessTokenCookie(HttpServletResponse response, String accessToken) { - Cookie accessCookie = new Cookie(ACCESS_TOKEN_NAME, accessToken); - accessCookie.setHttpOnly(true); - accessCookie.setSecure(false); // 운영환경에서는 true로 변경 (HTTPS) - accessCookie.setPath("/"); - accessCookie.setMaxAge(ACCESS_TOKEN_EXPIRE_TIME); + Cookie accessCookie = createCookie(ACCESS_TOKEN_NAME, accessToken, ACCESS_TOKEN_EXPIRE_TIME); response.addCookie(accessCookie); } public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { - Cookie refreshCookie = new Cookie(REFRESH_TOKEN_NAME, refreshToken); - refreshCookie.setHttpOnly(true); - refreshCookie.setSecure(false); // 운영환경에서는 true로 변경 (HTTPS) - refreshCookie.setPath("/"); - refreshCookie.setMaxAge(REFRESH_TOKEN_EXPIRE_TIME); + Cookie refreshCookie = createCookie(REFRESH_TOKEN_NAME, refreshToken, REFRESH_TOKEN_EXPIRE_TIME); response.addCookie(refreshCookie); } @@ -41,12 +44,23 @@ public void clearTokenCookies(HttpServletResponse response) { clearCookie(response, REFRESH_TOKEN_NAME); } + /** + * 쿠키를 생성합니다. + */ + private Cookie createCookie(String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(HTTP_ONLY); + cookie.setSecure(SECURE_IN_PRODUCTION); + cookie.setPath(COOKIE_PATH); + cookie.setMaxAge(maxAge); + return cookie; + } + + /** + * 쿠키를 삭제합니다 (MaxAge를 0으로 설정). + */ private void clearCookie(HttpServletResponse response, String cookieName) { - Cookie cookie = new Cookie(cookieName, null); - cookie.setHttpOnly(true); - cookie.setSecure(false); - cookie.setPath("/"); - cookie.setMaxAge(0); + Cookie cookie = createCookie(cookieName, null, COOKIE_EXPIRE_IMMEDIATELY); response.addCookie(cookie); } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index dd8093dd..305f4733 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -1,12 +1,12 @@ package com.ai.lawyer.global.jwt; -import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.MemberAdapter; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -19,47 +19,72 @@ import java.util.List; @Component -@RequiredArgsConstructor @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final TokenProvider tokenProvider; - private final CookieUtil cookieUtil; - private final MemberRepository memberRepository; + private TokenProvider tokenProvider; + private CookieUtil cookieUtil; + private MemberRepository memberRepository; + private OAuth2MemberRepository oauth2MemberRepository; + + public JwtAuthenticationFilter() { + // Default constructor for testing + } + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setTokenProvider(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setCookieUtil(CookieUtil cookieUtil) { + this.cookieUtil = cookieUtil; + } + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setMemberRepository(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setOauth2MemberRepository(OAuth2MemberRepository oauth2MemberRepository) { + this.oauth2MemberRepository = oauth2MemberRepository; + } + + // 권한 관련 상수 + private static final String ROLE_PREFIX = "ROLE_"; + private static final String DEFAULT_ROLE = "USER"; + + // 로그 메시지 상수 + private static final String LOG_TOKEN_EXPIRED = "액세스 토큰 만료, 리프레시 토큰으로 갱신 시도"; + private static final String LOG_INVALID_TOKEN = "유효하지 않은 액세스 토큰, 리프레시 토큰으로 갱신 시도"; + private static final String LOG_NO_REFRESH_TOKEN = "리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요"; + private static final String LOG_LOGIN_ID_EXTRACTION_FAILED = "loginId 추출 실패 - 쿠키 클리어"; + private static final String LOG_INVALID_REFRESH_TOKEN = "유효하지 않은 리프레시 토큰 - 쿠키 클리어: {}"; + private static final String LOG_MEMBER_NOT_FOUND = "존재하지 않는 회원 - 쿠키 클리어: {}"; + private static final String LOG_TOKEN_REFRESH_SUCCESS = "토큰 자동 갱신 성공: {}"; + private static final String LOG_TOKEN_REFRESH_FAILED = "토큰 갱신 처리 실패: {}"; + private static final String LOG_JWT_AUTH_ERROR = "JWT 인증 처리 중 오류 발생: {}"; + private static final String LOG_MEMBER_ID_EXTRACTION_FAILED = "토큰에서 memberId를 추출할 수 없습니다."; + private static final String LOG_SET_AUTH_FAILED = "인증 정보 설정 실패: {}"; @Override protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable FilterChain filterChain) throws ServletException, IOException { + // 테스트 환경에서 의존성이 없는 경우 필터 스킵 + if (tokenProvider == null || cookieUtil == null || memberRepository == null) { + if (filterChain != null) { + filterChain.doFilter(request, response); + } + return; + } + if (request != null && response != null) { try { - // 1. 쿠키에서 액세스 토큰 확인 - String accessToken = cookieUtil.getAccessTokenFromCookies(request); - - if (accessToken != null) { - // 액세스 토큰이 있는 경우 검증 - TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken); - - if (validationResult == TokenProvider.TokenValidationResult.VALID) { - // 유효한 액세스 토큰 - 인증 처리 - setAuthentication(accessToken); - log.debug("유효한 액세스 토큰으로 인증 완료"); - } else if (validationResult == TokenProvider.TokenValidationResult.EXPIRED) { - // 만료된 액세스 토큰 - 리프레시 토큰으로 갱신 시도 - log.info("액세스 토큰 만료, 리프레시 토큰으로 갱신 시도"); - handleTokenRefresh(request, response, accessToken); - } else { - // 유효하지 않은 액세스 토큰 - 리프레시 토큰 확인 - log.warn("유효하지 않은 액세스 토큰, 리프레시 토큰으로 갱신 시도"); - handleTokenRefresh(request, response, null); - } - } else { - // 4. 액세스 토큰이 없는 경우 바로 리프레시 토큰 확인 - log.debug("액세스 토큰이 없음, 리프레시 토큰 확인"); - handleTokenRefresh(request, response, null); - } + processAuthentication(request, response); } catch (Exception e) { - log.error("JWT 인증 처리 중 오류 발생: {}", e.getMessage(), e); + log.error(LOG_JWT_AUTH_ERROR, e.getMessage(), e); clearAuthenticationAndCookies(response); } } @@ -69,6 +94,44 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable } } + /** + * 인증 프로세스를 처리합니다. + */ + private void processAuthentication(HttpServletRequest request, HttpServletResponse response) { + String accessToken = cookieUtil.getAccessTokenFromCookies(request); + + if (accessToken != null) { + handleAccessToken(request, response, accessToken); + } else { + // 액세스 토큰이 없는 경우 바로 리프레시 토큰 확인 + handleTokenRefresh(request, response, null); + } + } + + /** + * 액세스 토큰을 검증하고 처리합니다. + */ + private void handleAccessToken(HttpServletRequest request, HttpServletResponse response, String accessToken) { + TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken); + + switch (validationResult) { + case VALID: + // 유효한 액세스 토큰 - 인증 처리 + setAuthentication(accessToken); + break; + case EXPIRED: + // 만료된 액세스 토큰 - 리프레시 토큰으로 갱신 시도 + log.info(LOG_TOKEN_EXPIRED); + handleTokenRefresh(request, response, accessToken); + break; + case INVALID: + // 유효하지 않은 액세스 토큰 - 리프레시 토큰 확인 + log.warn(LOG_INVALID_TOKEN); + handleTokenRefresh(request, response, null); + break; + } + } + /** * JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다. * @param token JWT 액세스 토큰 @@ -76,93 +139,132 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable private void setAuthentication(String token) { try { Long memberId = tokenProvider.getMemberIdFromToken(token); + String loginId = tokenProvider.getLoginIdFromToken(token); String role = tokenProvider.getRoleFromToken(token); if (memberId == null) { - log.warn("토큰에서 memberId를 추출할 수 없습니다."); + log.warn(LOG_MEMBER_ID_EXTRACTION_FAILED); return; } // Spring Security 권한 형식으로 변환 - String authority = "ROLE_" + (role != null ? role : "USER"); + String authority = buildAuthority(role); List authorities = List.of(new SimpleGrantedAuthority(authority)); // memberId를 principal로 하는 인증 객체 생성 + // getName()은 memberId를 반환 (PollController 호환) + // getDetails()는 loginId를 반환 (MemberController 호환) UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(memberId, null, authorities); + new UsernamePasswordAuthenticationToken(memberId, null, authorities) { + @Override + public String getName() { + return String.valueOf(memberId); + } + + @Override + public Object getDetails() { + return loginId; + } + }; SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { - log.warn("인증 정보 설정 실패: {}", e.getMessage()); + log.warn(LOG_SET_AUTH_FAILED, e.getMessage()); } } + /** + * 권한 문자열을 생성합니다. + */ + private String buildAuthority(String role) { + return ROLE_PREFIX + (role != null ? role : DEFAULT_ROLE); + } + /** * 리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다. * RTR(Refresh Token Rotation) 패턴을 적용하여 새로운 토큰 쌍을 생성합니다. */ private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) { try { - // 리프레시 토큰 확인 String refreshToken = cookieUtil.getRefreshTokenFromCookies(request); if (refreshToken == null) { - // 리프레시 토큰이 없을 경우 쿠키 클리어 - log.info("리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요"); + log.info(LOG_NO_REFRESH_TOKEN); clearAuthenticationAndCookies(response); return; } - // loginId 추출 시도 (만료된 토큰이 있으면 그것에서, 없으면 리프레시 토큰으로 찾기) - String loginId = null; - if (expiredAccessToken != null) { - loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken); - } - - // 만료된 토큰에서 추출 실패 시 리프레시 토큰으로 사용자 찾기 - if (loginId == null) { - loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); - } - + String loginId = extractLoginId(expiredAccessToken, refreshToken); if (loginId == null) { - log.warn("loginId 추출 실패 - 쿠키 클리어"); + log.warn(LOG_LOGIN_ID_EXTRACTION_FAILED); clearAuthenticationAndCookies(response); return; } - // 3. 리프레시 토큰이 Redis의 저장값과 동일한지 검증 if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { - log.info("유효하지 않은 리프레시 토큰 - 쿠키 클리어: {}", loginId); + log.info(LOG_INVALID_REFRESH_TOKEN, loginId); clearAuthenticationAndCookies(response); return; } - // 회원 정보 조회 - Member member = memberRepository.findByLoginId(loginId).orElse(null); + MemberAdapter member = findMemberByLoginId(loginId); if (member == null) { - log.warn("존재하지 않는 회원 - 쿠키 클리어: {}", loginId); + log.warn(LOG_MEMBER_NOT_FOUND, loginId); clearAuthenticationAndCookies(response); return; } - // RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제 - tokenProvider.deleteAllTokens(loginId); + refreshTokensAndSetAuthentication(response, loginId, member); + log.info(LOG_TOKEN_REFRESH_SUCCESS, loginId); - // 새로운 액세스 토큰과 리프레시 토큰 생성 - String newAccessToken = tokenProvider.generateAccessToken(member); - String newRefreshToken = tokenProvider.generateRefreshToken(member); - - // 새로운 토큰들을 쿠키에 설정 - cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); + } catch (Exception e) { + log.error(LOG_TOKEN_REFRESH_FAILED, e.getMessage(), e); + clearAuthenticationAndCookies(response); + } + } - // 새로운 액세스 토큰으로 인증 설정 - setAuthentication(newAccessToken); + /** + * loginId를 추출합니다 (만료된 토큰 또는 리프레시 토큰에서). + */ + private String extractLoginId(String expiredAccessToken, String refreshToken) { + String loginId = null; + if (expiredAccessToken != null) { + loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken); + } + if (loginId == null) { + loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); + } + return loginId; + } - log.info("토큰 자동 갱신 성공: {}", loginId); + /** + * loginId로 회원 정보를 조회합니다 (Member 또는 OAuth2Member). + */ + private MemberAdapter findMemberByLoginId(String loginId) { + MemberAdapter member = memberRepository.findByLoginId(loginId).orElse(null); - } catch (Exception e) { - log.error("토큰 갱신 처리 실패: {}", e.getMessage(), e); - clearAuthenticationAndCookies(response); + if (member == null && oauth2MemberRepository != null) { + member = oauth2MemberRepository.findByLoginId(loginId).orElse(null); } + + return member; + } + + /** + * RTR 패턴으로 토큰을 갱신하고 인증을 설정합니다. + */ + private void refreshTokensAndSetAuthentication(HttpServletResponse response, String loginId, MemberAdapter member) { + // RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제 + tokenProvider.deleteAllTokens(loginId); + + // 새로운 액세스 토큰과 리프레시 토큰 생성 + String newAccessToken = tokenProvider.generateAccessToken(member); + String newRefreshToken = tokenProvider.generateRefreshToken(member); + + // 새로운 토큰들을 쿠키에 설정 + cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); + + // 새로운 액세스 토큰으로 인증 설정 + setAuthentication(newAccessToken); } /** @@ -174,8 +276,6 @@ private void clearAuthenticationAndCookies(HttpServletResponse response) { // 쿠키 클리어 cookieUtil.clearTokenCookies(response); - - log.debug("인증 정보 및 쿠키 클리어 완료"); } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index e7361383..88ac99a7 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -1,6 +1,5 @@ package com.ai.lawyer.global.jwt; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.global.config.JwtProperties; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; @@ -23,81 +22,119 @@ public class TokenProvider { private final JwtProperties jwtProperties; private final RedisTemplate redisTemplate; + // Redis Key 관련 상수 private static final String TOKEN_PREFIX = "tokens:"; private static final String ACCESS_TOKEN_FIELD = "accessToken"; private static final String ACCESS_TOKEN_EXPIRY_FIELD = "accessTokenExpiry"; private static final String REFRESH_TOKEN_FIELD = "refreshToken"; private static final String REFRESH_TOKEN_EXPIRY_FIELD = "refreshTokenExpiry"; - private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 + + // 토큰 만료 시간 상수 + private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 (초 단위) + private static final long MILLIS_PER_SECOND = 1000L; + + // JWT Claim 상수 + private static final String CLAIM_LOGIN_ID = "loginId"; + private static final String CLAIM_MEMBER_ID = "memberId"; + private static final String CLAIM_ROLE = "role"; + + // 로그 메시지 상수 + private static final String LOG_ACCESS_TOKEN_SAVED = "=== Access token Hash 저장 성공: key={}, expiry={} ==="; + private static final String LOG_ACCESS_TOKEN_SAVE_FAILED = "=== Access token Hash 저장 실패: {} ==="; + private static final String LOG_REFRESH_TOKEN_SAVED = "=== Refresh token Hash 저장 성공: key={}, value={}, expiry={} ==="; + private static final String LOG_REFRESH_TOKEN_SAVE_FAILED = "=== Refresh token Hash 저장 실패: {} ==="; + private static final String LOG_HASH_VERIFIED = "=== Hash 저장 확인: storedToken={}, storedExpiry={} ==="; + private static final String LOG_ALL_TOKENS_DELETED = "=== 모든 토큰 Hash 삭제 완료: loginId={} ==="; + private static final String LOG_EXPIRED_JWT = "만료된 JWT 토큰: {}"; + private static final String LOG_INVALID_JWT = "유효하지 않은 JWT 토큰: {}"; + private static final String LOG_MEMBER_ID_EXTRACTION_FAILED = "토큰에서 회원 ID 추출 실패: {}"; + private static final String LOG_ROLE_EXTRACTION_FAILED = "토큰에서 역할 정보 추출 실패: {}"; + private static final String LOG_LOGIN_ID_EXTRACTION_FAILED = "토큰에서 로그인 ID 추출 실패: {}"; + private static final String LOG_EXPIRED_TOKEN_LOGIN_ID_FAILED = "만료된 토큰에서 로그인 ID 추출 실패: {}"; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)); } - public String generateAccessToken(Member member) { + public String generateAccessToken(com.ai.lawyer.domain.member.entity.MemberAdapter member) { Date now = new Date(); - Date expiry = new Date(now.getTime() + jwtProperties.getAccessToken().getExpirationSeconds() * 1000); + Date expiry = new Date(now.getTime() + jwtProperties.getAccessToken().getExpirationSeconds() * MILLIS_PER_SECOND); String accessToken = Jwts.builder() .setIssuedAt(now) .setExpiration(expiry) - .claim("loginid", member.getLoginId()) - .claim("memberId", member.getMemberId()) - .claim("role", member.getRole().name()) + .claim(CLAIM_LOGIN_ID, member.getLoginId()) + .claim(CLAIM_MEMBER_ID, member.getMemberId()) + .claim(CLAIM_ROLE, member.getRole().name()) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); // Redis Hash에 액세스 토큰 정보 저장 - try { - String loginId = member.getLoginId(); - String tokenKey = TOKEN_PREFIX + loginId; + saveAccessTokenToRedis(member.getLoginId(), accessToken, expiry); + + return accessToken; + } - // Hash에 액세스 토큰과 만료시점 저장 + /** + * Redis에 액세스 토큰을 저장합니다. + */ + private void saveAccessTokenToRedis(String loginId, String accessToken, Date expiry) { + try { + String tokenKey = buildTokenKey(loginId); redisTemplate.opsForHash().put(tokenKey, ACCESS_TOKEN_FIELD, accessToken); redisTemplate.opsForHash().put(tokenKey, ACCESS_TOKEN_EXPIRY_FIELD, String.valueOf(expiry.getTime())); - - // 전체 Hash에 TTL 설정 (리프레시 토큰 만료시간으로 설정) redisTemplate.expire(tokenKey, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); - - log.info("=== Access token Hash 저장 성공: key={}, expiry={} ===", tokenKey, expiry); + log.info(LOG_ACCESS_TOKEN_SAVED, tokenKey, expiry); } catch (Exception e) { - log.error("=== Access token Hash 저장 실패: {} ===", e.getMessage(), e); + log.error(LOG_ACCESS_TOKEN_SAVE_FAILED, e.getMessage(), e); } - - return accessToken; } - public String generateRefreshToken(Member member) { + public String generateRefreshToken(com.ai.lawyer.domain.member.entity.MemberAdapter member) { String refreshToken = UUID.randomUUID().toString(); - Date now = new Date(); - Date refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME * 1000); + Date refreshExpiry = new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME * MILLIS_PER_SECOND); // Redis Hash에 리프레시 토큰 정보 저장 - try { - String loginId = member.getLoginId(); - String tokenKey = TOKEN_PREFIX + loginId; + saveRefreshTokenToRedis(member.getLoginId(), refreshToken, refreshExpiry); - // Hash에 리프레시 토큰과 만료시점 저장 - redisTemplate.opsForHash().put(tokenKey, REFRESH_TOKEN_FIELD, refreshToken); - redisTemplate.opsForHash().put(tokenKey, REFRESH_TOKEN_EXPIRY_FIELD, String.valueOf(refreshExpiry.getTime())); + return refreshToken; + } - // 전체 Hash에 TTL 설정 (리프레시 토큰 만료시간으로 설정) + /** + * Redis에 리프레시 토큰을 저장하고 검증합니다. + */ + private void saveRefreshTokenToRedis(String loginId, String refreshToken, Date expiry) { + try { + String tokenKey = buildTokenKey(loginId); + redisTemplate.opsForHash().put(tokenKey, REFRESH_TOKEN_FIELD, refreshToken); + redisTemplate.opsForHash().put(tokenKey, REFRESH_TOKEN_EXPIRY_FIELD, String.valueOf(expiry.getTime())); redisTemplate.expire(tokenKey, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); - - log.info("=== Refresh token Hash 저장 성공: key={}, value={}, expiry={} ===", tokenKey, refreshToken, refreshExpiry); + log.info(LOG_REFRESH_TOKEN_SAVED, tokenKey, refreshToken, expiry); // 저장 확인 - String storedToken = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_FIELD); - String storedExpiryStr = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_EXPIRY_FIELD); - if (storedExpiryStr != null) { - long storedExpiry = Long.parseLong(storedExpiryStr); - log.info("=== Hash 저장 확인: storedToken={}, storedExpiry={} ===", storedToken, new Date(storedExpiry)); - } + verifyTokenStorageInRedis(tokenKey); } catch (Exception e) { - log.error("=== Refresh token Hash 저장 실패: {} ===", e.getMessage(), e); + log.error(LOG_REFRESH_TOKEN_SAVE_FAILED, e.getMessage(), e); } + } - return refreshToken; + /** + * Redis에 저장된 토큰을 검증합니다. + */ + private void verifyTokenStorageInRedis(String tokenKey) { + String storedToken = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_FIELD); + String storedExpiryStr = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_EXPIRY_FIELD); + if (storedExpiryStr != null) { + long storedExpiry = Long.parseLong(storedExpiryStr); + log.info(LOG_HASH_VERIFIED, storedToken, new Date(storedExpiry)); + } + } + + /** + * 토큰 키를 생성합니다. + */ + private String buildTokenKey(String loginId) { + return TOKEN_PREFIX + loginId; } /** @@ -113,10 +150,10 @@ public TokenValidationResult validateTokenWithResult(String token) { .parseClaimsJws(token); return TokenValidationResult.VALID; } catch (ExpiredJwtException e) { - log.warn("만료된 JWT 토큰: {}", e.getMessage()); + log.warn(LOG_EXPIRED_JWT, e.getMessage()); return TokenValidationResult.EXPIRED; } catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException | SecurityException e) { - log.warn("유효하지 않은 JWT 토큰: {}", e.getMessage()); + log.warn(LOG_INVALID_JWT, e.getMessage()); return TokenValidationResult.INVALID; } } @@ -128,47 +165,41 @@ public enum TokenValidationResult { } public Long getMemberIdFromToken(String token) { - try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.get("memberId", Long.class); - } catch (Exception e) { - log.warn("토큰에서 회원 ID 추출 실패: {}", e.getMessage()); - return null; - } + return getClaimFromToken(token, CLAIM_MEMBER_ID, Long.class, LOG_MEMBER_ID_EXTRACTION_FAILED); } public String getRoleFromToken(String token) { - try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.get("role", String.class); - } catch (Exception e) { - log.warn("토큰에서 역할 정보 추출 실패: {}", e.getMessage()); - return null; - } + return getClaimFromToken(token, CLAIM_ROLE, String.class, LOG_ROLE_EXTRACTION_FAILED); } public String getLoginIdFromToken(String token) { + return getClaimFromToken(token, CLAIM_LOGIN_ID, String.class, LOG_LOGIN_ID_EXTRACTION_FAILED); + } + + /** + * 토큰에서 특정 Claim을 추출하는 공통 메서드 + */ + private T getClaimFromToken(String token, String claimKey, Class claimType, String errorLogMessage) { try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.get("loginid", String.class); // loginid claim에서 추출 + Claims claims = parseClaims(token); + return claims.get(claimKey, claimType); } catch (Exception e) { - log.warn("토큰에서 로그인 ID 추출 실패: {}", e.getMessage()); + log.warn(errorLogMessage, e.getMessage()); return null; } } + /** + * JWT 토큰을 파싱하여 Claims를 반환합니다. + */ + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + /** * 만료된 토큰에서도 loginId를 추출합니다. * @param token JWT 토큰 (만료되어도 괜찮음) @@ -176,36 +207,32 @@ public String getLoginIdFromToken(String token) { */ public String getLoginIdFromExpiredToken(String token) { try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.get("loginid", String.class); + Claims claims = parseClaims(token); + return claims.get(CLAIM_LOGIN_ID, String.class); } catch (ExpiredJwtException e) { // 만료된 토큰이지만 claim은 추출 가능 - return e.getClaims().get("loginid", String.class); + return e.getClaims().get(CLAIM_LOGIN_ID, String.class); } catch (Exception e) { - log.warn("만료된 토큰에서 로그인 ID 추출 실패: {}", e.getMessage()); + log.warn(LOG_EXPIRED_TOKEN_LOGIN_ID_FAILED, e.getMessage()); return null; } } public boolean validateRefreshToken(String loginId, String refreshToken) { - String tokenKey = TOKEN_PREFIX + loginId; + String tokenKey = buildTokenKey(loginId); String storedToken = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_FIELD); return refreshToken.equals(storedToken); } public void deleteAllTokens(String loginId) { - String tokenKey = TOKEN_PREFIX + loginId; + String tokenKey = buildTokenKey(loginId); redisTemplate.delete(tokenKey); - log.info("=== 모든 토큰 Hash 삭제 완료: loginId={} ===", loginId); + log.info(LOG_ALL_TOKENS_DELETED, loginId); } /** * 리프레시 토큰으로 사용자명을 찾습니다. - * Redis에서 모든 토큰 Hash를 순회하며 일치하는 리프레시 토큰을 찾습니다. + * Redis 모든 토큰 Hash를 순회하며 일치하는 리프레시 토큰을 찾습니다. * @param refreshToken 찾을 리프레시 토큰 * @return 사용자명 또는 null */ diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java new file mode 100644 index 00000000..b82b7ed1 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java @@ -0,0 +1,118 @@ +package com.ai.lawyer.global.oauth; + +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final OAuth2MemberRepository oauth2MemberRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + log.info("OAuth2 로그인 시도: provider={}", registrationId); + + OAuth2UserInfo userInfo = getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); + + if (userInfo.getEmail() == null) { + throw new OAuth2AuthenticationException("이메일을 가져올 수 없습니다."); + } + + // 이메일로 기존 OAuth2 회원 조회 + OAuth2Member member = oauth2MemberRepository.findByLoginId(userInfo.getEmail()) + .orElse(null); + + if (member == null) { + // 신규 OAuth2 회원 생성 + log.info("신규 OAuth2 사용자: email={}, provider={}", userInfo.getEmail(), registrationId); + member = createOAuth2Member(userInfo); + } else { + // 기존 OAuth2 회원 로그인 + log.info("기존 OAuth2 사용자 로그인: email={}, provider={}", userInfo.getEmail(), registrationId); + } + + oauth2MemberRepository.save(member); + + return new PrincipalDetails(member, oAuth2User.getAttributes()); + } + + private OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map attributes) { + if ("kakao".equalsIgnoreCase(registrationId)) { + return new KakaoUserInfo(attributes); + } else if ("naver".equalsIgnoreCase(registrationId)) { + return new NaverUserInfo(attributes); + } + throw new OAuth2AuthenticationException("지원하지 않는 로그인 방식입니다: " + registrationId); + } + + private OAuth2Member createOAuth2Member(OAuth2UserInfo userInfo) { + // 출생년도를 나이로 계산 + Integer age = calculateAgeFromBirthYear(userInfo.getBirthYear()); + + com.ai.lawyer.domain.member.entity.Member.Gender gender = null; + if (userInfo.getGender() != null) { + try { + gender = com.ai.lawyer.domain.member.entity.Member.Gender.valueOf(userInfo.getGender()); + } catch (IllegalArgumentException e) { + log.warn("성별 파싱 실패: {}", userInfo.getGender()); + } + } + + String email = userInfo.getEmail(); + + return OAuth2Member.builder() + .loginId(email) // loginId와 email을 동일하게 설정 + .email(email) // email 컬럼에도 저장 + .name(userInfo.getName() != null ? userInfo.getName() : "정보없음") + .age(age != null ? age : 20) // 기본값 + .gender(gender != null ? gender : com.ai.lawyer.domain.member.entity.Member.Gender.OTHER) // 기본값 + .provider(OAuth2Member.Provider.valueOf(userInfo.getProvider())) + .providerId(userInfo.getProviderId()) + .role(com.ai.lawyer.domain.member.entity.Member.Role.USER) + .build(); + } + + /** + * 출생년도를 현재 나이로 계산 + * @param birthYear 출생년도 (예: "1990") + * @return 현재 나이, 파싱 실패 시 null + */ + private Integer calculateAgeFromBirthYear(String birthYear) { + if (birthYear == null || birthYear.trim().isEmpty()) { + return null; + } + + try { + int year = Integer.parseInt(birthYear.trim()); + int currentYear = java.time.Year.now().getValue(); + int age = currentYear - year + 1; // 한국 나이 계산 (만 나이 + 1) + + // 유효성 검사 + if (age < 0 || age > 150) { + log.warn("비정상적인 나이 계산됨: birthYear={}, age={}", birthYear, age); + return null; + } + + return age; + } catch (NumberFormatException e) { + log.warn("출생년도 파싱 실패: {}", birthYear); + return null; + } + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/KakaoUserInfo.java b/backend/src/main/java/com/ai/lawyer/global/oauth/KakaoUserInfo.java new file mode 100644 index 00000000..99515386 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/KakaoUserInfo.java @@ -0,0 +1,72 @@ +package com.ai.lawyer.global.oauth; + +import java.util.Map; + +public class KakaoUserInfo implements OAuth2UserInfo { + private final Map attributes; + private final Map kakaoAccount; + private final Map profile; + + public KakaoUserInfo(Map attributes) { + this.attributes = attributes; + this.kakaoAccount = extractMapFromAttributes(attributes, "kakao_account"); + this.profile = extractMapFromAttributes(kakaoAccount, "profile"); + } + + private Map extractMapFromAttributes(Map source, String key) { + if (source == null) { + return null; + } + Object obj = source.get(key); + if (obj instanceof Map) { + try { + @SuppressWarnings("unchecked") + Map map = (Map) obj; + return map; + } catch (ClassCastException e) { + return null; + } + } + return null; + } + + @Override + public String getProviderId() { + return String.valueOf(attributes.get("id")); + } + + @Override + public String getProvider() { + return "KAKAO"; + } + + @Override + public String getEmail() { + return kakaoAccount != null ? (String) kakaoAccount.get("email") : null; + } + + @Override + public String getName() { + return profile != null ? (String) profile.get("nickname") : null; + } + + @Override + public String getGender() { + // 카카오 성별: "male", "female" + if (kakaoAccount != null && kakaoAccount.containsKey("gender")) { + String gender = (String) kakaoAccount.get("gender"); + if ("male".equalsIgnoreCase(gender)) return "MALE"; + if ("female".equalsIgnoreCase(gender)) return "FEMALE"; + } + return null; + } + + @Override + public String getBirthYear() { + // 카카오 출생년도: "1990", "1985" 등 + if (kakaoAccount != null && kakaoAccount.containsKey("birthyear")) { + return (String) kakaoAccount.get("birthyear"); + } + return null; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/NaverUserInfo.java b/backend/src/main/java/com/ai/lawyer/global/oauth/NaverUserInfo.java new file mode 100644 index 00000000..48c948a4 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/NaverUserInfo.java @@ -0,0 +1,71 @@ +package com.ai.lawyer.global.oauth; + +import lombok.Getter; + +import java.util.Map; + +public class NaverUserInfo implements OAuth2UserInfo { + @Getter + private final Map attributes; + private final Map response; + + public NaverUserInfo(Map attributes) { + this.attributes = attributes; + this.response = extractResponseMap(attributes); + } + + private Map extractResponseMap(Map attributes) { + Object responseObj = attributes.get("response"); + if (responseObj instanceof Map) { + try { + @SuppressWarnings("unchecked") + Map responseMap = (Map) responseObj; + return responseMap; + } catch (ClassCastException e) { + return null; + } + } + return null; + } + + @Override + public String getProviderId() { + return response != null ? (String) response.get("id") : null; + } + + @Override + public String getProvider() { + return "NAVER"; + } + + @Override + public String getEmail() { + return response != null ? (String) response.get("email") : null; + } + + @Override + public String getName() { + return response != null ? (String) response.get("name") : null; + } + + @Override + public String getGender() { + // 네이버 성별: "M", "F", "U" + if (response != null && response.containsKey("gender")) { + String gender = (String) response.get("gender"); + if ("M".equalsIgnoreCase(gender)) return "MALE"; + if ("F".equalsIgnoreCase(gender)) return "FEMALE"; + } + return null; + } + + @Override + public String getBirthYear() { + // 네이버 생년: "1990" + if (response != null && response.containsKey("birthyear")) { + return (String) response.get("birthyear"); + } + return null; + } + +} diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java new file mode 100644 index 00000000..cd9f7dde --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java @@ -0,0 +1,40 @@ +package com.ai.lawyer.global.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${custom.oauth2.failure-url:http://localhost:8080/api/auth/oauth2/callback/failure}") + private String failureUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + log.error("OAuth2 로그인 실패: {}", exception.getMessage()); + + // 에러 메시지를 URL-safe하게 인코딩 + String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류"; + String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); + + String targetUrl = UriComponentsBuilder.fromUriString(failureUrl) + .queryParam("error", encodedError) + .build(true) // true로 설정하여 이미 인코딩된 값을 사용 + .toUriString(); + + log.info("OAuth2 로그인 실패, 백엔드 콜백으로 리다이렉트: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java new file mode 100644 index 00000000..9799a8a0 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java @@ -0,0 +1,52 @@ +package com.ai.lawyer.global.oauth; + +import com.ai.lawyer.global.jwt.CookieUtil; +import com.ai.lawyer.global.jwt.TokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private final CookieUtil cookieUtil; + + @Value("${custom.oauth2.redirect-url:http://localhost:8080/api/auth/oauth2/callback/success}") + private String redirectUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); + com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); + + log.info("OAuth2 로그인 성공: memberId={}, email={}", + member.getMemberId(), member.getLoginId()); + + // JWT 토큰 생성 (Redis 저장 포함) + String accessToken = tokenProvider.generateAccessToken(member); + String refreshToken = tokenProvider.generateRefreshToken(member); + + // 쿠키에 토큰 설정 + cookieUtil.setTokenCookies(response, accessToken, refreshToken); + + // 백엔드 콜백 엔드포인트로 리다이렉트 + String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) + .build() + .toUriString(); + + log.info("OAuth2 로그인 완료, 백엔드 콜백으로 리다이렉트: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2UserInfo.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2UserInfo.java new file mode 100644 index 00000000..a32826c8 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2UserInfo.java @@ -0,0 +1,10 @@ +package com.ai.lawyer.global.oauth; + +public interface OAuth2UserInfo { + String getProviderId(); + String getProvider(); + String getEmail(); + String getName(); + String getGender(); + String getBirthYear(); // ageRange 대신 birthYear 사용 +} diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/PrincipalDetails.java b/backend/src/main/java/com/ai/lawyer/global/oauth/PrincipalDetails.java new file mode 100644 index 00000000..688a699d --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/PrincipalDetails.java @@ -0,0 +1,58 @@ +package com.ai.lawyer.global.oauth; + +import com.ai.lawyer.domain.member.entity.MemberAdapter; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class PrincipalDetails implements OAuth2User, UserDetails { + + private final MemberAdapter member; + private final Map attributes; + + // OAuth2 로그인용 + public PrincipalDetails(MemberAdapter member, Map attributes) { + this.member = member; + this.attributes = attributes; + } + + // OAuth2User 메서드 + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return member.getName(); + } + + // UserDetails 메서드 + @Override + public Collection getAuthorities() { + return Collections.singletonList( + new SimpleGrantedAuthority("ROLE_" + member.getRole().name()) + ); + } + + @Override + public String getPassword() { + // OAuth2Member는 password가 없으므로 null 반환 + if (member.isLocalMember()) { + return ((com.ai.lawyer.domain.member.entity.Member) member).getPassword(); + } + return null; + } + + @Override + public String getUsername() { + return member.getLoginId(); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index 6b3c7d46..adb21a7a 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -1,7 +1,10 @@ package com.ai.lawyer.global.security; import com.ai.lawyer.global.jwt.JwtAuthenticationFilter; -import lombok.RequiredArgsConstructor; +import com.ai.lawyer.global.oauth.CustomOAuth2UserService; +import com.ai.lawyer.global.oauth.OAuth2FailureHandler; +import com.ai.lawyer.global.oauth.OAuth2SuccessHandler; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -21,10 +24,26 @@ @Configuration @EnableWebSecurity -@RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + + @Value("${custom.cors.allowed-origins:http://localhost:3000}") + private String allowedOrigins; + + public SecurityConfig( + JwtAuthenticationFilter jwtAuthenticationFilter, + @org.springframework.beans.factory.annotation.Autowired(required = false) CustomOAuth2UserService customOAuth2UserService, + @org.springframework.beans.factory.annotation.Autowired(required = false) OAuth2SuccessHandler oAuth2SuccessHandler, + @org.springframework.beans.factory.annotation.Autowired(required = false) OAuth2FailureHandler oAuth2FailureHandler) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.customOAuth2UserService = customOAuth2UserService; + this.oAuth2SuccessHandler = oAuth2SuccessHandler; + this.oAuth2FailureHandler = oAuth2FailureHandler; + } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -43,10 +62,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( "/api/auth/login", "/api/auth/signup", + "/api/auth/refresh", "/api/auth/sendEmail", "/api/auth/verifyEmail", "/api/auth/passwordReset", - "/api/public/**").permitAll() + "/api/auth/oauth2/**", + "/api/public/**", + "/oauth2/**", + "/login/oauth2/**").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/api/posts/**").permitAll() .requestMatchers("/api/precedent/**").permitAll() @@ -55,9 +78,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/h2-console/**").permitAll() .requestMatchers("/api/chat/**").permitAll() .anyRequest().authenticated() - ) - // JWT 필터 추가 - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + ); + + // OAuth2 로그인 설정 (빈이 있을 때만) + if (customOAuth2UserService != null && oAuth2SuccessHandler != null && oAuth2FailureHandler != null) { + http.oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ); + } + + // JWT 필터 추가 + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -70,11 +105,7 @@ public PasswordEncoder passwordEncoder() { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of( - "http://localhost:3000", - "https://www.trybalaw.com", - "https://api.trybalaw.com", - "https://balaw.vercel.app")); + configuration.setAllowedOrigins(List.of(allowedOrigins.split(","))); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization","Content-Type","Accept","X-Requested-With")); configuration.setAllowCredentials(true); diff --git a/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java b/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java index 7c5c3991..9f813e26 100644 --- a/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java +++ b/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java @@ -84,7 +84,7 @@ public ResponseEntity getTokenInfo(@PathVariable String loginId) { StringBuilder info = new StringBuilder(); String tokenKey = "tokens:" + loginId; - // Hash에서 토큰 정보 조회 + // hash 토큰 정보 조회 String accessToken = (String) redisTemplate.opsForHash().get(tokenKey, "accessToken"); String accessTokenExpiryStr = (String) redisTemplate.opsForHash().get(tokenKey, "accessTokenExpiry"); Long accessTokenExpiry = accessTokenExpiryStr != null ? Long.parseLong(accessTokenExpiryStr) : null; diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 2bf06544..454c3122 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -16,3 +16,25 @@ spring: port: ${DEV_REDIS_PORT} password: ${DEV_REDIS_PASSWORD} embedded: false + security: + oauth2: + client: + registration: + kakao: + redirect-uri: ${DEV_OAUTH2_KAKAO_REDIRECT_URI:http://localhost:8080/login/oauth2/code/kakao} + naver: + redirect-uri: ${DEV_OAUTH2_NAVER_REDIRECT_URI:http://localhost:8080/login/oauth2/code/naver} + ai: + vectorstore: + qdrant: + host: ${DEV_QDRANT_HOST} + port: ${DEV_QDRANT_PORT} + +custom: + cors: + allowed-origins: ${DEV_CORS_ALLOWED_ORIGINS} + oauth2: + redirect-url: ${DEV_OAUTH2_SUCCESS_REDIRECT_URL} + failure-url: ${DEV_OAUTH2_FAILURE_REDIRECT_URL} + frontend: + url: ${DEV_FRONTEND_URL} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 0264022d..6d40ce1f 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -1,6 +1,4 @@ spring: - autoconfigure: - exclude: datasource: url: ${PROD_DATASOURCE_URL} driver-class-name: ${PROD_DATASOURCE_DRIVER} @@ -29,6 +27,14 @@ spring: port: ${PROD_REDIS_PORT} password: ${PROD_REDIS_PASSWORD} embedded: false + security: + oauth2: + client: + registration: + kakao: + redirect-uri: ${PROD_OAUTH2_KAKAO_REDIRECT_URI:https://api.trybalaw.com/login/oauth2/code/kakao} + naver: + redirect-uri: ${PROD_OAUTH2_NAVER_REDIRECT_URI:https://api.trybalaw.com/login/oauth2/code/naver} ai: vectorstore: qdrant: @@ -36,8 +42,17 @@ spring: port: ${PROD_QDRANT_PORT} collection-name: legal_cases vector-size: 1536 +custom: + cors: + allowed-origins: ${PROD_CORS_ALLOWED_ORIGINS} + oauth2: + redirect-url: ${PROD_OAUTH2_SUCCESS_REDIRECT_URL} + failure-url: ${PROD_OAUTH2_FAILURE_REDIRECT_URL} + frontend: + url: ${PROD_FRONTEND_URL} + sentry: dsn: ${PROD_SENTRY_DSN} - environment: "dev" - release: "my-app@0.1.0-dev" + environment: "prod" + release: "my-app@0.1.0-prod" send-default-pii: true \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 9fa2446d..5cbf4b72 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -54,8 +54,8 @@ spring: vectorstore: qdrant: - host: localhost - port: 6334 + host: ${SPRING_AI_VECTORSTORE_QDRANT_HOST} + port: ${SPRING_AI_VECTORSTORE_QDRANT_PORT} collection-name: "legal_cases" vector-size: 1536 @@ -89,23 +89,36 @@ spring: client-id: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID} client-secret: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_SECRET} client-name: Kakao - scope: profile_nickname, account_email + redirect-uri: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - account_email + - name + - gender + - birthyear naver: client-id: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_CLIENT_ID} client-secret: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_CLIENT_SECRET} client-name: Naver - scope: profile, email - provider: - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - naver: - authorization-uri: https://nid.naver.com/oauth2.0/authorize - token-uri: https://nid.naver.com/oauth2.0/token - user-info-uri: https://openapi.naver.com/v1/nid/me - user-name-attribute: response + redirect-uri: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: + - email + - name + - gender + - age + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response springdoc: default-produces-media-type: application/json;charset=UTF-8 @@ -130,7 +143,14 @@ management: show-details: never # 프로브 용도면 never 권장(민감정보 차단) custom: + cors: + allowed-origins: ${CUSTOM_CORS_ALLOWED_ORIGINS} jwt: secretKey: ${CUSTOM_JWT_SECRET_KEY} accessToken: expirationSeconds: ${CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS} + oauth2: + redirect-url: ${CUSTOM_OAUTH2_REDIRECT_URL} + failure-url: ${CUSTOM_OAUTH2_FAILURE_URL} + frontend: + url: ${CUSTOM_FRONTEND_URL} diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java index 0f99e84e..5b2561cd 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java @@ -6,8 +6,6 @@ import com.ai.lawyer.domain.member.exception.MemberAuthenticationException; import com.ai.lawyer.domain.member.exception.MemberExceptionHandler; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -47,9 +45,6 @@ class MemberControllerTest { @Mock private MemberService memberService; - @Mock - private HttpServletRequest request; - @Mock private HttpServletResponse response; @@ -247,56 +242,51 @@ void logout_Success_Unauthenticated() { } @Test - @DisplayName("토큰 재발급 성공 - 쿠키에서 Refresh Token 추출하여 Redis 검증") + @DisplayName("토큰 재발급 성공 - Authentication 기반") void refreshToken_Success() { // given - Cookie[] cookies = {new Cookie("refreshToken", "validRefreshToken")}; - given(request.getCookies()).willReturn(cookies); - given(memberService.refreshToken(eq("validRefreshToken"), eq(response))) - .willReturn(memberResponse); + Long memberId = 1L; + Authentication testAuth = new UsernamePasswordAuthenticationToken( + memberId, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + given(memberService.getMemberById(memberId)).willReturn(memberResponse); // when - ResponseEntity result = memberController.refreshToken(request, response); + ResponseEntity result = memberController.refreshToken(testAuth); // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isEqualTo(memberResponse); - - // 쿠키에서 refreshToken이 정상적으로 추출되어 서비스에 전달되는지 검증 - verify(memberService).refreshToken(eq("validRefreshToken"), eq(response)); + verify(memberService).getMemberById(memberId); } @Test - @DisplayName("토큰 재발급 실패 - 리프레시 토큰 없음") - void refreshToken_Fail_NoRefreshToken() { - // given - given(request.getCookies()).willReturn(null); + @DisplayName("토큰 재발급 실패 - 인증 정보 없음") + void refreshToken_Fail_NoAuthentication() { + // given - authentication이 null인 경우 - // when & then - 예외가 발생해야 함 - try { - memberController.refreshToken(request, response); - } catch (MemberAuthenticationException e) { - assertThat(e.getMessage()).isEqualTo("리프레시 토큰이 없습니다."); - } - - verify(memberService, never()).refreshToken(anyString(), any()); + // when & then + assertThatThrownBy(() -> memberController.refreshToken(null)) + .isInstanceOf(MemberAuthenticationException.class) + .hasMessage("인증이 필요합니다."); } @Test - @DisplayName("토큰 재발급 실패 - 유효하지 않은 토큰") - void refreshToken_Fail_InvalidToken() { + @DisplayName("토큰 재발급 실패 - Principal 없음") + void refreshToken_Fail_NoPrincipal() { // given - Cookie[] cookies = {new Cookie("refreshToken", "invalidRefreshToken")}; - given(request.getCookies()).willReturn(cookies); - given(memberService.refreshToken(eq("invalidRefreshToken"), eq(response))) - .willThrow(new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.")); + Authentication testAuth = new UsernamePasswordAuthenticationToken( + null, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); // when & then - assertThatThrownBy(() -> memberController.refreshToken(request, response)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("유효하지 않은 리프레시 토큰입니다."); - - verify(memberService).refreshToken(eq("invalidRefreshToken"), eq(response)); + assertThatThrownBy(() -> memberController.refreshToken(testAuth)) + .isInstanceOf(MemberAuthenticationException.class) + .hasMessage("인증이 필요합니다."); } @Test diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java new file mode 100644 index 00000000..a432edb9 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java @@ -0,0 +1,424 @@ +package com.ai.lawyer.domain.member.service; + +import com.ai.lawyer.domain.member.dto.MemberResponse; +import com.ai.lawyer.domain.member.dto.OAuth2LoginTestRequest; +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import com.ai.lawyer.global.jwt.CookieUtil; +import com.ai.lawyer.global.jwt.TokenProvider; +import com.ai.lawyer.global.email.service.EmailAuthService; +import com.ai.lawyer.global.email.service.EmailService; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MemberService OAuth2 테스트") +class MemberServiceOAuth2Test { + + private static final Logger log = LoggerFactory.getLogger(MemberServiceOAuth2Test.class); + + @Mock + private MemberRepository memberRepository; + + @Mock + private OAuth2MemberRepository oauth2MemberRepository; + + @Mock + private TokenProvider tokenProvider; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private EmailService emailService; + + @Mock + private EmailAuthService emailAuthService; + + @Mock + private HttpServletResponse response; + + private MemberService memberService; + + private OAuth2Member kakaoMember; + private OAuth2Member naverMember; + + @BeforeEach + void setUp() { + // MemberService 생성 + memberService = new MemberService( + memberRepository, + passwordEncoder, + tokenProvider, + cookieUtil, + emailService, + emailAuthService + ); + memberService.setOauth2MemberRepository(oauth2MemberRepository); + + kakaoMember = OAuth2Member.builder() + .loginId("kakao@test.com") + .email("kakao@test.com") + .name("카카오사용자") + .age(35) + .gender(Member.Gender.MALE) + .provider(OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + + naverMember = OAuth2Member.builder() + .loginId("naver@test.com") + .email("naver@test.com") + .name("네이버사용자") + .age(30) + .gender(Member.Gender.FEMALE) + .provider(OAuth2Member.Provider.NAVER) + .providerId("naver456") + .role(Member.Role.USER) + .build(); + } + + @Test + @DisplayName("OAuth2 회원 - 토큰 재발급 성공 (카카오)") + void refreshToken_Success_KakaoOAuth2Member() { + // given + log.info("=== OAuth2 카카오 회원 토큰 재발급 테스트 시작 ==="); + String refreshToken = "validRefreshToken"; + + given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("kakao@test.com"); + given(tokenProvider.validateRefreshToken("kakao@test.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.empty()); + given(oauth2MemberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.of(kakaoMember)); + given(tokenProvider.generateAccessToken(kakaoMember)).willReturn("newAccessToken"); + given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn("newRefreshToken"); + log.info("Mock 설정 완료: OAuth2 카카오 회원 존재, 토큰 유효"); + + // when + log.info("OAuth2 회원 토큰 재발급 서비스 호출 중..."); + MemberResponse result = memberService.refreshToken(refreshToken, response); + log.info("OAuth2 회원 토큰 재발급 완료: 이메일={}", result.getEmail()); + + // then + log.info("검증 시작: OAuth2 회원 토큰 재발급 결과 확인"); + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("kakao@test.com"); + assertThat(result.getEmail()).isEqualTo("kakao@test.com"); + assertThat(result.getName()).isEqualTo("카카오사용자"); + + verify(tokenProvider).findUsernameByRefreshToken(refreshToken); + verify(tokenProvider).validateRefreshToken("kakao@test.com", refreshToken); + verify(memberRepository).findByLoginId("kakao@test.com"); + verify(oauth2MemberRepository).findByLoginId("kakao@test.com"); + verify(tokenProvider).deleteAllTokens("kakao@test.com"); + verify(tokenProvider).generateAccessToken(kakaoMember); + verify(tokenProvider).generateRefreshToken(kakaoMember); + verify(cookieUtil).setTokenCookies(response, "newAccessToken", "newRefreshToken"); + log.info("=== OAuth2 카카오 회원 토큰 재발급 테스트 완료 ==="); + } + + @Test + @DisplayName("OAuth2 회원 - 토큰 재발급 성공 (네이버)") + void refreshToken_Success_NaverOAuth2Member() { + // given + log.info("=== OAuth2 네이버 회원 토큰 재발급 테스트 시작 ==="); + String refreshToken = "validRefreshToken"; + + given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("naver@test.com"); + given(tokenProvider.validateRefreshToken("naver@test.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("naver@test.com")).willReturn(Optional.empty()); + given(oauth2MemberRepository.findByLoginId("naver@test.com")).willReturn(Optional.of(naverMember)); + given(tokenProvider.generateAccessToken(naverMember)).willReturn("newAccessToken"); + given(tokenProvider.generateRefreshToken(naverMember)).willReturn("newRefreshToken"); + log.info("Mock 설정 완료: OAuth2 네이버 회원 존재, 토큰 유효"); + + // when + MemberResponse result = memberService.refreshToken(refreshToken, response); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("naver@test.com"); + assertThat(result.getEmail()).isEqualTo("naver@test.com"); + assertThat(result.getName()).isEqualTo("네이버사용자"); + + verify(oauth2MemberRepository).findByLoginId("naver@test.com"); + verify(tokenProvider).generateAccessToken(naverMember); + verify(tokenProvider).generateRefreshToken(naverMember); + log.info("=== OAuth2 네이버 회원 토큰 재발급 테스트 완료 ==="); + } + + @Test + @DisplayName("OAuth2 회원 - ID로 회원 조회 성공 (카카오)") + void getMemberById_Success_KakaoOAuth2Member() { + // given + log.info("=== OAuth2 카카오 회원 ID로 조회 테스트 시작 ==="); + Long memberId = 1L; + // memberId 필드 설정 (리플렉션 사용) + org.springframework.test.util.ReflectionTestUtils.setField(kakaoMember, "memberId", memberId); + + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(kakaoMember)); + log.info("Mock 설정 완료: OAuth2 카카오 회원 존재"); + + // when + log.info("OAuth2 회원 조회 서비스 호출 중..."); + MemberResponse result = memberService.getMemberById(memberId); + log.info("OAuth2 회원 조회 완료: 이메일={}", result.getEmail()); + + // then + log.info("검증 시작: OAuth2 회원 조회 결과 확인"); + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(memberId); + assertThat(result.getLoginId()).isEqualTo("kakao@test.com"); + assertThat(result.getEmail()).isEqualTo("kakao@test.com"); + assertThat(result.getName()).isEqualTo("카카오사용자"); + + verify(memberRepository).findById(memberId); + verify(oauth2MemberRepository).findById(memberId); + log.info("=== OAuth2 카카오 회원 ID로 조회 테스트 완료 ==="); + } + + @Test + @DisplayName("OAuth2 회원 - ID로 회원 조회 성공 (네이버)") + void getMemberById_Success_NaverOAuth2Member() { + // given + Long memberId = 2L; + org.springframework.test.util.ReflectionTestUtils.setField(naverMember, "memberId", memberId); + + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(naverMember)); + + // when + MemberResponse result = memberService.getMemberById(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(memberId); + assertThat(result.getLoginId()).isEqualTo("naver@test.com"); + assertThat(result.getEmail()).isEqualTo("naver@test.com"); + assertThat(result.getName()).isEqualTo("네이버사용자"); + + verify(oauth2MemberRepository).findById(memberId); + } + + @Test + @DisplayName("OAuth2 회원 - ID로 회원 조회 실패 (존재하지 않는 회원)") + void getMemberById_Fail_OAuth2MemberNotFound() { + // given + Long memberId = 999L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when and then + assertThatThrownBy(() -> memberService.getMemberById(memberId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); + + verify(memberRepository).findById(memberId); + verify(oauth2MemberRepository).findById(memberId); + } + + @Test + @DisplayName("OAuth2 로그인 테스트 - 신규 카카오 회원 생성 및 토큰 발급") + void oauth2LoginTest_NewKakaoMember() { + // given + log.info("=== OAuth2 로그인 테스트 (신규 카카오 회원) 시작 ==="); + OAuth2LoginTestRequest request = OAuth2LoginTestRequest.builder() + .email("new-kakao@test.com") + .name("신규카카오") + .age(25) + .gender("MALE") + .provider("KAKAO") + .providerId("new-kakao-123") + .build(); + + OAuth2Member newMember = OAuth2Member.builder() + .loginId("new-kakao@test.com") + .email("new-kakao@test.com") + .name("신규카카오") + .age(25) + .gender(Member.Gender.MALE) + .provider(OAuth2Member.Provider.KAKAO) + .providerId("new-kakao-123") + .role(Member.Role.USER) + .build(); + + given(oauth2MemberRepository.findByLoginId("new-kakao@test.com")).willReturn(Optional.empty()); + given(oauth2MemberRepository.save(any(OAuth2Member.class))).willReturn(newMember); + given(tokenProvider.generateAccessToken(any(OAuth2Member.class))).willReturn("accessToken"); + given(tokenProvider.generateRefreshToken(any(OAuth2Member.class))).willReturn("refreshToken"); + log.info("Mock 설정 완료: 신규 OAuth2 회원 생성, 토큰 생성 준비"); + + // when + log.info("OAuth2 로그인 테스트 서비스 호출 중..."); + MemberResponse result = memberService.oauth2LoginTest(request, response); + log.info("OAuth2 로그인 테스트 완료: 이메일={}", result.getEmail()); + + // then + log.info("검증 시작: 신규 OAuth2 회원 생성 및 토큰 발급 확인"); + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("new-kakao@test.com"); + assertThat(result.getEmail()).isEqualTo("new-kakao@test.com"); + assertThat(result.getName()).isEqualTo("신규카카오"); + + verify(oauth2MemberRepository).findByLoginId("new-kakao@test.com"); + verify(oauth2MemberRepository).save(any(OAuth2Member.class)); + verify(tokenProvider).generateAccessToken(any(OAuth2Member.class)); + verify(tokenProvider).generateRefreshToken(any(OAuth2Member.class)); + verify(cookieUtil).setTokenCookies(response, "accessToken", "refreshToken"); + log.info("=== OAuth2 로그인 테스트 (신규 카카오 회원) 완료 ==="); + } + + @Test + @DisplayName("OAuth2 로그인 테스트 - 기존 네이버 회원 로그인 및 토큰 발급") + void oauth2LoginTest_ExistingNaverMember() { + // given + log.info("=== OAuth2 로그인 테스트 (기존 네이버 회원) 시작 ==="); + OAuth2LoginTestRequest request = OAuth2LoginTestRequest.builder() + .email("naver@test.com") + .name("네이버사용자") + .age(30) + .gender("FEMALE") + .provider("NAVER") + .providerId("naver456") + .build(); + + given(oauth2MemberRepository.findByLoginId("naver@test.com")).willReturn(Optional.of(naverMember)); + given(tokenProvider.generateAccessToken(naverMember)).willReturn("accessToken"); + given(tokenProvider.generateRefreshToken(naverMember)).willReturn("refreshToken"); + log.info("Mock 설정 완료: 기존 OAuth2 회원 존재, 토큰 생성 준비"); + + // when + log.info("OAuth2 로그인 테스트 서비스 호출 중..."); + MemberResponse result = memberService.oauth2LoginTest(request, response); + log.info("OAuth2 로그인 테스트 완료: 이메일={}", result.getEmail()); + + // then + log.info("검증 시작: 기존 OAuth2 회원 로그인 및 토큰 발급 확인"); + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("naver@test.com"); + assertThat(result.getEmail()).isEqualTo("naver@test.com"); + assertThat(result.getName()).isEqualTo("네이버사용자"); + + verify(oauth2MemberRepository).findByLoginId("naver@test.com"); + verify(oauth2MemberRepository, never()).save(any(OAuth2Member.class)); + verify(tokenProvider).generateAccessToken(naverMember); + verify(tokenProvider).generateRefreshToken(naverMember); + verify(cookieUtil).setTokenCookies(response, "accessToken", "refreshToken"); + log.info("=== OAuth2 로그인 테스트 (기존 네이버 회원) 완료 ==="); + } + + @Test + @DisplayName("OAuth2 회원 - 로그아웃 성공") + void logout_Success_OAuth2Member() { + // given + log.info("=== OAuth2 회원 로그아웃 테스트 시작 ==="); + String loginId = "kakao@test.com"; + log.info("로그아웃 대상 OAuth2 사용자: {}", loginId); + + // when + log.info("OAuth2 회원 로그아웃 서비스 호출 중..."); + memberService.logout(loginId, response); + log.info("OAuth2 회원 로그아웃 완료"); + + // then + log.info("검증 시작: OAuth2 회원 Redis 토큰 삭제 및 쿠키 클리어 확인"); + verify(tokenProvider).deleteAllTokens(loginId); + log.info("OAuth2 회원 Redis에서 모든 토큰 삭제 호출 확인"); + verify(cookieUtil).clearTokenCookies(response); + log.info("OAuth2 회원 쿠키에서 토큰 클리어 호출 확인"); + log.info("=== OAuth2 회원 로그아웃 테스트 완료 ==="); + } + + @Test + @DisplayName("OAuth2 회원 - JWT 토큰에서 loginId 추출 성공") + void extractLoginIdFromToken_Success_OAuth2Member() { + // given + String token = "valid.oauth2.jwt.token"; + String expectedLoginId = "kakao@test.com"; + given(tokenProvider.getLoginIdFromToken(token)).willReturn(expectedLoginId); + + // when + String result = memberService.extractLoginIdFromToken(token); + + // then + assertThat(result).isEqualTo(expectedLoginId); + verify(tokenProvider).getLoginIdFromToken(token); + } + + @Test + @DisplayName("로컬 회원 우선 조회 후 OAuth2 회원 조회 - refreshToken") + void refreshToken_LocalMemberFirst_ThenOAuth2Member() { + // given + log.info("=== 로컬 회원 우선 조회 후 OAuth2 회원 조회 테스트 시작 ==="); + String refreshToken = "validRefreshToken"; + + given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("kakao@test.com"); + given(tokenProvider.validateRefreshToken("kakao@test.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.empty()); + log.info("로컬 회원 조회 결과: 없음"); + given(oauth2MemberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.of(kakaoMember)); + log.info("OAuth2 회원 조회 결과: 존재"); + given(tokenProvider.generateAccessToken(kakaoMember)).willReturn("newAccessToken"); + given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn("newRefreshToken"); + + // when + MemberResponse result = memberService.refreshToken(refreshToken, response); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("kakao@test.com"); + + verify(memberRepository).findByLoginId("kakao@test.com"); + verify(oauth2MemberRepository).findByLoginId("kakao@test.com"); + log.info("=== 로컬 회원 우선 조회 후 OAuth2 회원 조회 테스트 완료 ==="); + } + + @Test + @DisplayName("로컬 회원 우선 조회 후 OAuth2 회원 조회 - getMemberById") + void getMemberById_LocalMemberFirst_ThenOAuth2Member() { + // given + log.info("=== ID로 조회 시 로컬 회원 우선 조회 후 OAuth2 회원 조회 테스트 시작 ==="); + Long memberId = 1L; + org.springframework.test.util.ReflectionTestUtils.setField(kakaoMember, "memberId", memberId); + + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + log.info("로컬 회원 조회 결과: 없음"); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(kakaoMember)); + log.info("OAuth2 회원 조회 결과: 존재"); + + // when + MemberResponse result = memberService.getMemberById(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(memberId); + assertThat(result.getLoginId()).isEqualTo("kakao@test.com"); + + verify(memberRepository).findById(memberId); + verify(oauth2MemberRepository).findById(memberId); + log.info("=== ID로 조회 시 로컬 회원 우선 조회 후 OAuth2 회원 조회 테스트 완료 ==="); + } +} diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 758fbaf6..a02d487b 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -3,6 +3,7 @@ import com.ai.lawyer.domain.member.dto.*; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import com.ai.lawyer.global.jwt.CookieUtil; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.email.service.EmailService; @@ -12,7 +13,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; @@ -34,6 +34,9 @@ class MemberServiceTest { @Mock private MemberRepository memberRepository; + @Mock + private OAuth2MemberRepository oauth2MemberRepository; + @Mock private PasswordEncoder passwordEncoder; @@ -52,7 +55,6 @@ class MemberServiceTest { @Mock private HttpServletResponse response; - @InjectMocks private MemberService memberService; private MemberSignupRequest signupRequest; @@ -64,6 +66,17 @@ class MemberServiceTest { @BeforeEach void setUp() { + // MemberService 생성 + memberService = new MemberService( + memberRepository, + passwordEncoder, + tokenProvider, + cookieUtil, + emailService, + emailAuthService + ); + memberService.setOauth2MemberRepository(oauth2MemberRepository); + signupRequest = MemberSignupRequest.builder() .loginId("test@example.com") .password("password123") @@ -91,37 +104,22 @@ void setUp() { @Test @DisplayName("회원가입 성공") void signup_Success() { - // given - log.info("=== 회원가입 성공 테스트 시작 ==="); - log.info("테스트 데이터: 이메일={}", signupRequest.getLoginId()); - given(memberRepository.existsByLoginId(signupRequest.getLoginId())).willReturn(false); given(passwordEncoder.encode(signupRequest.getPassword())).willReturn("encodedPassword"); given(memberRepository.save(any(Member.class))).willReturn(member); - log.info("Mock 설정 완료: 이메일 중복 없음, 닉네임 중복 없음, 비밀번호 인코딩 성공"); - // when - log.info("회원가입 서비스 호출 중..."); MemberResponse result = memberService.signup(signupRequest, response); - log.info("회원가입 완료: memberId={}", result.getMemberId()); - // then - log.info("검증 시작: 반환된 회원 정보 확인"); - assertThat(result).as("회원가입 결과가 null이 아님").isNotNull(); - assertThat(result.getLoginId()).as("로그인 ID 일치").isEqualTo("test@example.com"); - assertThat(result.getAge()).as("나이 일치").isEqualTo(25); - assertThat(result.getGender()).as("성별 일치").isEqualTo(Member.Gender.MALE); - assertThat(result.getName()).as("이름 일치").isEqualTo("테스트"); - assertThat(result.getRole()).as("기본 역할이 USER로 설정됨").isEqualTo(Member.Role.USER); - log.info("회원 정보 검증 완료"); + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("test@example.com"); + assertThat(result.getAge()).isEqualTo(25); + assertThat(result.getGender()).isEqualTo(Member.Gender.MALE); + assertThat(result.getName()).isEqualTo("테스트"); + assertThat(result.getRole()).isEqualTo(Member.Role.USER); - log.info(MOCK_VERIFICATION_START_LOG); verify(memberRepository).existsByLoginId(signupRequest.getLoginId()); verify(passwordEncoder).encode(signupRequest.getPassword()); - log.info("비밀번호 인코딩 호출 확인"); verify(memberRepository).save(any(Member.class)); - log.info("회원 저장 호출 확인"); - log.info("=== 회원가입 성공 테스트 완료 ==="); } @Test diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java index a7021bb2..3017c391 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java @@ -145,4 +145,26 @@ void t6() throws Exception { .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } + + @Test + @DisplayName("게시글 페이징 API") + void t7() throws Exception { + java.util.List postList = java.util.List.of( + com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build() + ); + org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); + org.springframework.data.domain.PageImpl page = new org.springframework.data.domain.PageImpl<>(postList, pageable, 1); + Mockito.when(postService.getPostsPaged(Mockito.any(org.springframework.data.domain.Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/api/posts/paged") + .param("page", "0") + .param("size", "10") + .cookie(new Cookie("accessToken", "valid-access-token"))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.content").isArray()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.page").value(0)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.size").value(10)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.totalPages").value(1)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.totalElements").value(1)); + } } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java index 339b596b..0c174b26 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/service/PostServiceTest.java @@ -127,4 +127,18 @@ void t11() { postService.patchUpdatePost(1L, updateDto); Mockito.verify(postService).patchUpdatePost(1L, updateDto); } + + @Test + @DisplayName("게시글 페이징 조회") + void t12() { + java.util.List postList = java.util.List.of(new PostDto()); + org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); + org.springframework.data.domain.PageImpl page = new org.springframework.data.domain.PageImpl<>(postList, pageable, 1); + Mockito.when(postService.getPostsPaged(pageable)).thenReturn(page); + + var result = postService.getPostsPaged(pageable); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getTotalPages()).isEqualTo(1); + } } diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java new file mode 100644 index 00000000..4ed1e125 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java @@ -0,0 +1,334 @@ +package com.ai.lawyer.global.jwt; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CookieUtil 테스트") +class CookieUtilTest { + + private static final Logger log = LoggerFactory.getLogger(CookieUtilTest.class); + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @InjectMocks + private CookieUtil cookieUtil; + + private static final String ACCESS_TOKEN = "testAccessToken"; + private static final String REFRESH_TOKEN = "testRefreshToken"; + private static final String ACCESS_TOKEN_NAME = "accessToken"; + private static final String REFRESH_TOKEN_NAME = "refreshToken"; + + @BeforeEach + void setUp() { + log.info("=== 테스트 초기화 ==="); + } + + @Test + @DisplayName("액세스 토큰과 리프레시 토큰을 쿠키에 설정") + void setTokenCookies_Success() { + // given + log.info("=== 토큰 쿠키 설정 테스트 시작 ==="); + log.info("액세스 토큰: {}, 리프레시 토큰: {}", ACCESS_TOKEN, REFRESH_TOKEN); + + // when + log.info("쿠키 설정 호출 중..."); + cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); + log.info("쿠키 설정 완료"); + + // then + log.info("검증: 2개의 쿠키(액세스, 리프레시)가 추가되었는지 확인"); + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response, times(2)).addCookie(cookieCaptor.capture()); + + var cookies = cookieCaptor.getAllValues(); + assertThat(cookies).hasSize(2); + + // 액세스 토큰 쿠키 검증 + Cookie accessCookie = cookies.getFirst(); + assertThat(accessCookie.getName()).isEqualTo(ACCESS_TOKEN_NAME); + assertThat(accessCookie.getValue()).isEqualTo(ACCESS_TOKEN); + assertThat(accessCookie.isHttpOnly()).isTrue(); + assertThat(accessCookie.getPath()).isEqualTo("/"); + assertThat(accessCookie.getMaxAge()).isEqualTo(5 * 60); // 5분 + log.info("액세스 토큰 쿠키 검증 완료: name={}, maxAge={}", accessCookie.getName(), accessCookie.getMaxAge()); + + // 리프레시 토큰 쿠키 검증 + Cookie refreshCookie = cookies.get(1); + assertThat(refreshCookie.getName()).isEqualTo(REFRESH_TOKEN_NAME); + assertThat(refreshCookie.getValue()).isEqualTo(REFRESH_TOKEN); + assertThat(refreshCookie.isHttpOnly()).isTrue(); + assertThat(refreshCookie.getPath()).isEqualTo("/"); + assertThat(refreshCookie.getMaxAge()).isEqualTo(7 * 24 * 60 * 60); // 7일 + log.info("리프레시 토큰 쿠키 검증 완료: name={}, maxAge={}", refreshCookie.getName(), refreshCookie.getMaxAge()); + + log.info("=== 토큰 쿠키 설정 테스트 완료 ==="); + } + + @Test + @DisplayName("액세스 토큰 단독 쿠키 설정") + void setAccessTokenCookie_Success() { + // given + log.info("=== 액세스 토큰 단독 쿠키 설정 테스트 시작 ==="); + + // when + cookieUtil.setAccessTokenCookie(response, ACCESS_TOKEN); + + // then + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertThat(cookie.getName()).isEqualTo(ACCESS_TOKEN_NAME); + assertThat(cookie.getValue()).isEqualTo(ACCESS_TOKEN); + assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.getMaxAge()).isEqualTo(5 * 60); + log.info("=== 액세스 토큰 단독 쿠키 설정 테스트 완료 ==="); + } + + @Test + @DisplayName("리프레시 토큰 단독 쿠키 설정") + void setRefreshTokenCookie_Success() { + // given + log.info("=== 리프레시 토큰 단독 쿠키 설정 테스트 시작 ==="); + + // when + cookieUtil.setRefreshTokenCookie(response, REFRESH_TOKEN); + + // then + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertThat(cookie.getName()).isEqualTo(REFRESH_TOKEN_NAME); + assertThat(cookie.getValue()).isEqualTo(REFRESH_TOKEN); + assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.getMaxAge()).isEqualTo(7 * 24 * 60 * 60); + log.info("=== 리프레시 토큰 단독 쿠키 설정 테스트 완료 ==="); + } + + @Test + @DisplayName("요청에서 액세스 토큰 쿠키 읽기 성공") + void getAccessTokenFromCookies_Success() { + // given + log.info("=== 액세스 토큰 쿠키 읽기 테스트 시작 ==="); + Cookie accessCookie = new Cookie(ACCESS_TOKEN_NAME, ACCESS_TOKEN); + Cookie[] cookies = {accessCookie}; + given(request.getCookies()).willReturn(cookies); + + // when + String token = cookieUtil.getAccessTokenFromCookies(request); + + // then + assertThat(token).isEqualTo(ACCESS_TOKEN); + log.info("읽은 액세스 토큰: {}", token); + log.info("=== 액세스 토큰 쿠키 읽기 테스트 완료 ==="); + } + + @Test + @DisplayName("요청에서 리프레시 토큰 쿠키 읽기 성공") + void getRefreshTokenFromCookies_Success() { + // given + log.info("=== 리프레시 토큰 쿠키 읽기 테스트 시작 ==="); + Cookie refreshCookie = new Cookie(REFRESH_TOKEN_NAME, REFRESH_TOKEN); + Cookie[] cookies = {refreshCookie}; + given(request.getCookies()).willReturn(cookies); + + // when + String token = cookieUtil.getRefreshTokenFromCookies(request); + + // then + assertThat(token).isEqualTo(REFRESH_TOKEN); + log.info("읽은 리프레시 토큰: {}", token); + log.info("=== 리프레시 토큰 쿠키 읽기 테스트 완료 ==="); + } + + @Test + @DisplayName("여러 쿠키 중에서 액세스 토큰 찾기") + void getAccessTokenFromCookies_MultipleCookies() { + // given + log.info("=== 여러 쿠키 중 액세스 토큰 찾기 테스트 시작 ==="); + Cookie[] cookies = { + new Cookie("otherCookie1", "value1"), + new Cookie(ACCESS_TOKEN_NAME, ACCESS_TOKEN), + new Cookie("otherCookie2", "value2") + }; + given(request.getCookies()).willReturn(cookies); + + // when + String token = cookieUtil.getAccessTokenFromCookies(request); + + // then + assertThat(token).isEqualTo(ACCESS_TOKEN); + log.info("여러 쿠키 중에서 액세스 토큰 찾기 성공"); + log.info("=== 여러 쿠키 중 액세스 토큰 찾기 테스트 완료 ==="); + } + + @Test + @DisplayName("쿠키가 없을 때 null 반환") + void getAccessTokenFromCookies_NoCookies() { + // given + log.info("=== 쿠키 없음 테스트 시작 ==="); + given(request.getCookies()).willReturn(null); + + // when + String token = cookieUtil.getAccessTokenFromCookies(request); + + // then + assertThat(token).isNull(); + log.info("쿠키 없을 때 null 반환 확인"); + log.info("=== 쿠키 없음 테스트 완료 ==="); + } + + @Test + @DisplayName("찾는 쿠키가 없을 때 null 반환") + void getAccessTokenFromCookies_TokenNotFound() { + // given + log.info("=== 토큰 쿠키 없음 테스트 시작 ==="); + Cookie[] cookies = { + new Cookie("otherCookie1", "value1"), + new Cookie("otherCookie2", "value2") + }; + given(request.getCookies()).willReturn(cookies); + + // when + String token = cookieUtil.getAccessTokenFromCookies(request); + + // then + assertThat(token).isNull(); + log.info("액세스 토큰 쿠키 없을 때 null 반환 확인"); + log.info("=== 토큰 쿠키 없음 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰 쿠키 클리어 - MaxAge 0으로 설정") + void clearTokenCookies_Success() { + // given + log.info("=== 토큰 쿠키 클리어 테스트 시작 ==="); + + // when + log.info("쿠키 클리어 호출 중..."); + cookieUtil.clearTokenCookies(response); + log.info("쿠키 클리어 완료"); + + // then + log.info("검증: 2개의 쿠키(액세스, 리프레시)가 삭제용으로 추가되었는지 확인"); + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response, times(2)).addCookie(cookieCaptor.capture()); + + var cookies = cookieCaptor.getAllValues(); + assertThat(cookies).hasSize(2); + + // 액세스 토큰 클리어 검증 + Cookie accessClearCookie = cookies.getFirst(); + assertThat(accessClearCookie.getName()).isEqualTo(ACCESS_TOKEN_NAME); + assertThat(accessClearCookie.getValue()).isNull(); + assertThat(accessClearCookie.getMaxAge()).isEqualTo(0); + assertThat(accessClearCookie.isHttpOnly()).isTrue(); + assertThat(accessClearCookie.getPath()).isEqualTo("/"); + log.info("액세스 토큰 쿠키 클리어 검증 완료: maxAge={}", accessClearCookie.getMaxAge()); + + // 리프레시 토큰 클리어 검증 + Cookie refreshClearCookie = cookies.get(1); + assertThat(refreshClearCookie.getName()).isEqualTo(REFRESH_TOKEN_NAME); + assertThat(refreshClearCookie.getValue()).isNull(); + assertThat(refreshClearCookie.getMaxAge()).isEqualTo(0); + assertThat(refreshClearCookie.isHttpOnly()).isTrue(); + assertThat(refreshClearCookie.getPath()).isEqualTo("/"); + log.info("리프레시 토큰 쿠키 클리어 검증 완료: maxAge={}", refreshClearCookie.getMaxAge()); + + log.info("=== 토큰 쿠키 클리어 테스트 완료 ==="); + } + + @Test + @DisplayName("HttpOnly 속성 확인 - XSS 공격 방어") + void cookieHttpOnlyAttribute_Security() { + // given + log.info("=== HttpOnly 속성 보안 테스트 시작 ==="); + log.info("HttpOnly 속성: JavaScript에서 쿠키 접근 차단, XSS 공격 방어"); + + // when + cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); + + // then + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response, times(2)).addCookie(cookieCaptor.capture()); + + cookieCaptor.getAllValues().forEach(cookie -> { + assertThat(cookie.isHttpOnly()).isTrue(); + log.info("쿠키 {}: HttpOnly=true (보안 설정 확인)", cookie.getName()); + }); + + log.info("=== HttpOnly 속성 보안 테스트 완료 ==="); + } + + @Test + @DisplayName("Path 속성 확인 - 모든 경로에서 쿠키 접근 가능") + void cookiePathAttribute_Accessibility() { + // given + log.info("=== Path 속성 테스트 시작 ==="); + log.info("Path=/: 모든 경로에서 쿠키 접근 가능"); + + // when + cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); + + // then + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response, times(2)).addCookie(cookieCaptor.capture()); + + cookieCaptor.getAllValues().forEach(cookie -> { + assertThat(cookie.getPath()).isEqualTo("/"); + log.info("쿠키 {}: Path=/ (모든 경로 접근 가능)", cookie.getName()); + }); + + log.info("=== Path 속성 테스트 완료 ==="); + } + + @Test + @DisplayName("토큰 만료 시간 확인 - 액세스 5분, 리프레시 7일") + void cookieMaxAgeAttribute_ExpiryTime() { + // given + log.info("=== 토큰 만료 시간 테스트 시작 ==="); + log.info("액세스 토큰 만료: 5분 (300초)"); + log.info("리프레시 토큰 만료: 7일 (604800초)"); + + // when + cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); + + // then + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response, times(2)).addCookie(cookieCaptor.capture()); + + var cookies = cookieCaptor.getAllValues(); + + Cookie accessCookie = cookies.getFirst(); + assertThat(accessCookie.getMaxAge()).isEqualTo(5 * 60); + log.info("액세스 토큰 만료 시간: {}초 (5분)", accessCookie.getMaxAge()); + + Cookie refreshCookie = cookies.get(1); + assertThat(refreshCookie.getMaxAge()).isEqualTo(7 * 24 * 60 * 60); + log.info("리프레시 토큰 만료 시간: {}초 (7일)", refreshCookie.getMaxAge()); + + log.info("=== 토큰 만료 시간 테스트 완료 ==="); + } +} diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java index 882ae528..91b9f5b3 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java @@ -1,7 +1,9 @@ package com.ai.lawyer.global.jwt; import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.OAuth2Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -9,9 +11,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import org.springframework.security.core.context.SecurityContextHolder; import java.util.Optional; @@ -21,6 +24,7 @@ import static org.mockito.BDDMockito.*; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) @DisplayName("JwtAuthenticationFilter 테스트") class JwtAuthenticationFilterTest { @@ -33,6 +37,9 @@ class JwtAuthenticationFilterTest { @Mock private MemberRepository memberRepository; + @Mock + private OAuth2MemberRepository oauth2MemberRepository; + @Mock private HttpServletRequest request; @@ -42,7 +49,6 @@ class JwtAuthenticationFilterTest { @Mock private FilterChain filterChain; - @InjectMocks private JwtAuthenticationFilter jwtAuthenticationFilter; private Member testMember; @@ -54,6 +60,13 @@ class JwtAuthenticationFilterTest { void setUp() { SecurityContextHolder.clearContext(); + // JwtAuthenticationFilter 생성 + jwtAuthenticationFilter = new JwtAuthenticationFilter(); + jwtAuthenticationFilter.setTokenProvider(tokenProvider); + jwtAuthenticationFilter.setCookieUtil(cookieUtil); + jwtAuthenticationFilter.setMemberRepository(memberRepository); + jwtAuthenticationFilter.setOauth2MemberRepository(oauth2MemberRepository); + testMember = Member.builder() .loginId("test@example.com") .password("encodedPassword") @@ -103,6 +116,7 @@ void doFilterInternal_ExpiredCookieToken_AutoRefreshSuccess() throws Exception { given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); + given(oauth2MemberRepository.findByLoginId("test@example.com")).willReturn(Optional.empty()); given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); @@ -152,6 +166,7 @@ void doFilterInternal_NoAccessToken_CheckRefreshToken() throws Exception { given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("test@example.com"); given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); + given(oauth2MemberRepository.findByLoginId("test@example.com")).willReturn(Optional.empty()); given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); @@ -184,6 +199,7 @@ void doFilterInternal_InvalidCookieToken_TryRefresh() throws Exception { given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("test@example.com"); given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); + given(oauth2MemberRepository.findByLoginId("test@example.com")).willReturn(Optional.empty()); given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); @@ -216,4 +232,117 @@ void doFilterInternal_NoTokens_ClearCookies() throws Exception { assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(filterChain).doFilter(request, response); } + + @Test + @DisplayName("OAuth2 회원 - 만료된 토큰으로 자동 리프레시 성공 (카카오)") + void doFilterInternal_OAuth2Member_KakaoRefresh() throws Exception { + // given + OAuth2Member kakaoMember = OAuth2Member.builder() + .loginId("kakao@test.com") + .email("kakao@test.com") + .name("카카오사용자") + .age(30) + .gender(Member.Gender.MALE) + .provider(OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + org.springframework.test.util.ReflectionTestUtils.setField(kakaoMember, "memberId", 10L); + + String newAccessToken = "newOAuth2AccessToken"; + String newRefreshToken = "newOAuth2RefreshToken"; + + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); + given(tokenProvider.validateTokenWithResult(expiredAccessToken)) + .willReturn(TokenProvider.TokenValidationResult.EXPIRED); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("kakao@test.com"); + given(tokenProvider.validateRefreshToken("kakao@test.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.empty()); + given(oauth2MemberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.of(kakaoMember)); + given(tokenProvider.generateAccessToken(kakaoMember)).willReturn(newAccessToken); + given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn(newRefreshToken); + given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(10L); + given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(memberRepository).findByLoginId("kakao@test.com"); + verify(oauth2MemberRepository).findByLoginId("kakao@test.com"); + verify(tokenProvider).deleteAllTokens("kakao@test.com"); + verify(tokenProvider).generateAccessToken(kakaoMember); + verify(tokenProvider).generateRefreshToken(kakaoMember); + verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(10L); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("OAuth2 회원 - 액세스 토큰 없이 리프레시 토큰으로만 갱신 성공 (네이버)") + void doFilterInternal_OAuth2Member_NaverRefreshOnly() throws Exception { + // given + OAuth2Member naverMember = OAuth2Member.builder() + .loginId("naver@test.com") + .email("naver@test.com") + .name("네이버사용자") + .age(25) + .gender(Member.Gender.FEMALE) + .provider(OAuth2Member.Provider.NAVER) + .providerId("naver456") + .role(Member.Role.USER) + .build(); + org.springframework.test.util.ReflectionTestUtils.setField(naverMember, "memberId", 20L); + + String newAccessToken = "newNaverAccessToken"; + String newRefreshToken = "newNaverRefreshToken"; + + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(null); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("naver@test.com"); + given(tokenProvider.validateRefreshToken("naver@test.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("naver@test.com")).willReturn(Optional.empty()); + given(oauth2MemberRepository.findByLoginId("naver@test.com")).willReturn(Optional.of(naverMember)); + given(tokenProvider.generateAccessToken(naverMember)).willReturn(newAccessToken); + given(tokenProvider.generateRefreshToken(naverMember)).willReturn(newRefreshToken); + given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(20L); + given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(memberRepository).findByLoginId("naver@test.com"); + verify(oauth2MemberRepository).findByLoginId("naver@test.com"); + verify(tokenProvider).deleteAllTokens("naver@test.com"); + verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(20L); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("OAuth2 회원 - 유효하지 않은 리프레시 토큰으로 쿠키 클리어") + void doFilterInternal_OAuth2Member_InvalidRefreshToken() throws Exception { + // given + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); + given(tokenProvider.validateTokenWithResult(expiredAccessToken)) + .willReturn(TokenProvider.TokenValidationResult.EXPIRED); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("kakao@test.com"); + given(tokenProvider.validateRefreshToken("kakao@test.com", refreshToken)).willReturn(false); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(tokenProvider).validateRefreshToken("kakao@test.com", refreshToken); + verify(cookieUtil).clearTokenCookies(response); + verify(memberRepository, never()).findByLoginId(anyString()); + verify(oauth2MemberRepository, never()).findByLoginId(anyString()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java index 6826c53e..1894ce91 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java @@ -115,7 +115,7 @@ void generateAccessToken_Success() { .parseClaimsJws(token) .getBody(); - assertThat(claims.get("loginid", String.class)).as("loginId claim 일치").isEqualTo("test@example.com"); + assertThat(claims.get("loginId", String.class)).as("loginId claim 일치").isEqualTo("test@example.com"); assertThat(claims.get("memberId", Long.class)).as("memberId claim 일치").isEqualTo(1L); assertThat(claims.get("role", String.class)).as("role claim 일치").isEqualTo("USER"); assertThat(claims.getIssuedAt()).as("발급 시간 존재").isNotNull(); diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java new file mode 100644 index 00000000..b8877181 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java @@ -0,0 +1,99 @@ +package com.ai.lawyer.global.oauth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Year; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("CustomOAuth2UserService 테스트") +class CustomOAuth2UserServiceTest { + + @Test + @DisplayName("카카오 UserInfo에서 사용자 정보를 추출한다") + void extractKakaoUserInfo() { + // given + Map attributes = createKakaoAttributes( + ); + + // when + KakaoUserInfo userInfo = new KakaoUserInfo(attributes); + + // then + assertThat(userInfo.getProviderId()).isEqualTo("123456789"); + assertThat(userInfo.getEmail()).isEqualTo("test@kakao.com"); + assertThat(userInfo.getName()).isEqualTo("홍길동"); + assertThat(userInfo.getGender()).isEqualTo("MALE"); + assertThat(userInfo.getBirthYear()).isEqualTo("1990"); + assertThat(userInfo.getProvider()).isEqualTo("KAKAO"); + } + + @Test + @DisplayName("네이버 UserInfo에서 사용자 정보를 추출한다") + void extractNaverUserInfo() { + // given + Map attributes = createNaverAttributes( + ); + + // when + NaverUserInfo userInfo = new NaverUserInfo(attributes); + + // then + assertThat(userInfo.getProviderId()).isEqualTo("abcdefg123"); + assertThat(userInfo.getEmail()).isEqualTo("test@naver.com"); + assertThat(userInfo.getName()).isEqualTo("김영희"); + assertThat(userInfo.getGender()).isEqualTo("FEMALE"); + assertThat(userInfo.getBirthYear()).isEqualTo("1995"); + assertThat(userInfo.getProvider()).isEqualTo("NAVER"); + } + + @Test + @DisplayName("출생년도가 올바르게 나이로 계산된다") + void birthYearConvertedToAge() { + // given + int currentYear = Year.now().getValue(); + String birthYear = "1990"; + int year = Integer.parseInt(birthYear); + + // when - 한국 나이 계산: 현재 년도 - 출생 년도 + 1 + int age = currentYear - year + 1; + + // then + assertThat(age).isGreaterThan(0); + assertThat(age).isLessThan(150); + } + + private Map createKakaoAttributes() { + Map profile = new HashMap<>(); + profile.put("nickname", "홍길동"); + + Map kakaoAccount = new HashMap<>(); + kakaoAccount.put("email", "test@kakao.com"); + kakaoAccount.put("gender", "male"); + kakaoAccount.put("birthyear", "1990"); + kakaoAccount.put("profile", profile); + + Map attributes = new HashMap<>(); + attributes.put("id", "123456789"); + attributes.put("kakao_account", kakaoAccount); + + return attributes; + } + + private Map createNaverAttributes() { + Map response = new HashMap<>(); + response.put("id", "abcdefg123"); + response.put("email", "test@naver.com"); + response.put("name", "김영희"); + response.put("gender", "F"); + response.put("birthyear", "1995"); + + Map attributes = new HashMap<>(); + attributes.put("response", response); + + return attributes; + } +} diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/KakaoUserInfoTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/KakaoUserInfoTest.java new file mode 100644 index 00000000..c2e7daf6 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/KakaoUserInfoTest.java @@ -0,0 +1,106 @@ +package com.ai.lawyer.global.oauth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("카카오 사용자 정보 파싱 테스트") +class KakaoUserInfoTest { + + @Test + @DisplayName("카카오 사용자 정보를 정상적으로 파싱한다") + void parseKakaoUserInfo() { + // given + Map attributes = createKakaoAttributes( + "홍길동", + "male", + "1990" + ); + + // when + KakaoUserInfo userInfo = new KakaoUserInfo(attributes); + + // then + assertThat(userInfo.getProviderId()).isEqualTo("123456789"); + assertThat(userInfo.getProvider()).isEqualTo("KAKAO"); + assertThat(userInfo.getEmail()).isEqualTo("test@kakao.com"); + assertThat(userInfo.getName()).isEqualTo("홍길동"); + assertThat(userInfo.getGender()).isEqualTo("MALE"); + assertThat(userInfo.getBirthYear()).isEqualTo("1990"); + } + + @Test + @DisplayName("여성 성별을 정상적으로 파싱한다") + void parseFemalGender() { + // given + Map attributes = createKakaoAttributes( + "김영희", + "female", + "1995" + ); + + // when + KakaoUserInfo userInfo = new KakaoUserInfo(attributes); + + // then + assertThat(userInfo.getGender()).isEqualTo("FEMALE"); + } + + @Test + @DisplayName("이메일이 없으면 null을 반환한다") + void returnNullWhenNoEmail() { + // given + Map attributes = new HashMap<>(); + attributes.put("id", "123456789"); + attributes.put("kakao_account", new HashMap<>()); + + // when + KakaoUserInfo userInfo = new KakaoUserInfo(attributes); + + // then + assertThat(userInfo.getEmail()).isNull(); + } + + @Test + @DisplayName("출생년도가 없으면 null을 반환한다") + void returnNullWhenNoBirthYear() { + // given + Map kakaoAccount = new HashMap<>(); + kakaoAccount.put("email", "test@kakao.com"); + + Map attributes = new HashMap<>(); + attributes.put("id", "123456789"); + attributes.put("kakao_account", kakaoAccount); + + // when + KakaoUserInfo userInfo = new KakaoUserInfo(attributes); + + // then + assertThat(userInfo.getBirthYear()).isNull(); + } + + private Map createKakaoAttributes( + String nickname, + String gender, + String birthYear + ) { + Map profile = new HashMap<>(); + profile.put("nickname", nickname); + + Map kakaoAccount = new HashMap<>(); + kakaoAccount.put("email", "test@kakao.com"); + kakaoAccount.put("gender", gender); + kakaoAccount.put("birthyear", birthYear); + kakaoAccount.put("profile", profile); + + Map attributes = new HashMap<>(); + attributes.put("id", "123456789"); + attributes.put("kakao_account", kakaoAccount); + + return attributes; + } +} diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/NaverUserInfoTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/NaverUserInfoTest.java new file mode 100644 index 00000000..47cc60df --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/NaverUserInfoTest.java @@ -0,0 +1,102 @@ +package com.ai.lawyer.global.oauth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("네이버 사용자 정보 파싱 테스트") +class NaverUserInfoTest { + + @Test + @DisplayName("네이버 사용자 정보를 정상적으로 파싱한다") + void parseNaverUserInfo() { + // given + Map attributes = createNaverAttributes( + "홍길동", + "M", + "1990" + ); + + // when + NaverUserInfo userInfo = new NaverUserInfo(attributes); + + // then + assertThat(userInfo.getProviderId()).isEqualTo("abcdefg123"); + assertThat(userInfo.getProvider()).isEqualTo("NAVER"); + assertThat(userInfo.getEmail()).isEqualTo("test@naver.com"); + assertThat(userInfo.getName()).isEqualTo("홍길동"); + assertThat(userInfo.getGender()).isEqualTo("MALE"); + assertThat(userInfo.getBirthYear()).isEqualTo("1990"); + } + + @Test + @DisplayName("여성 성별을 정상적으로 파싱한다") + void parseFemaleGender() { + // given + Map attributes = createNaverAttributes( + "김영희", + "F", + "1995" + ); + + // when + NaverUserInfo userInfo = new NaverUserInfo(attributes); + + // then + assertThat(userInfo.getGender()).isEqualTo("FEMALE"); + } + + @Test + @DisplayName("알 수 없는 성별은 null을 반환한다") + void returnNullForUnknownGender() { + // given + Map attributes = createNaverAttributes( + "홍길동", + "U", + "1990" + ); + + // when + NaverUserInfo userInfo = new NaverUserInfo(attributes); + + // then + assertThat(userInfo.getGender()).isNull(); + } + + @Test + @DisplayName("response가 없으면 null을 반환한다") + void returnNullWhenNoResponse() { + // given + Map attributes = new HashMap<>(); + + // when + NaverUserInfo userInfo = new NaverUserInfo(attributes); + + // then + assertThat(userInfo.getEmail()).isNull(); + assertThat(userInfo.getName()).isNull(); + assertThat(userInfo.getProviderId()).isNull(); + } + + private Map createNaverAttributes( + String name, + String gender, + String birthYear + ) { + Map response = new HashMap<>(); + response.put("id", "abcdefg123"); + response.put("email", "test@naver.com"); + response.put("name", name); + response.put("gender", gender); + response.put("birthyear", birthYear); + + Map attributes = new HashMap<>(); + attributes.put("response", response); + + return attributes; + } +} diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java new file mode 100644 index 00000000..9893a9fd --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java @@ -0,0 +1,203 @@ +package com.ai.lawyer.global.oauth; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import com.ai.lawyer.global.jwt.CookieUtil; +import com.ai.lawyer.global.jwt.TokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OAuth2SuccessHandler 테스트") +class OAuth2SuccessHandlerTest { + + private static final Logger log = LoggerFactory.getLogger(OAuth2SuccessHandlerTest.class); + + @Mock + private TokenProvider tokenProvider; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private Authentication authentication; + + @InjectMocks + private OAuth2SuccessHandler oauth2SuccessHandler; + + private OAuth2Member kakaoMember; + private OAuth2Member naverMember; + private PrincipalDetails kakaoPrincipalDetails; + private PrincipalDetails naverPrincipalDetails; + + @BeforeEach + void setUp() { + // Redirect URL 설정 + ReflectionTestUtils.setField(oauth2SuccessHandler, "redirectUrl", + "http://localhost:8080/api/auth/oauth2/callback/success"); + + // 카카오 회원 생성 + kakaoMember = OAuth2Member.builder() + .loginId("kakao@test.com") + .email("kakao@test.com") + .name("카카오사용자") + .age(30) + .gender(Member.Gender.MALE) + .provider(OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + ReflectionTestUtils.setField(kakaoMember, "memberId", 10L); + + // 네이버 회원 생성 + naverMember = OAuth2Member.builder() + .loginId("naver@test.com") + .email("naver@test.com") + .name("네이버사용자") + .age(25) + .gender(Member.Gender.FEMALE) + .provider(OAuth2Member.Provider.NAVER) + .providerId("naver456") + .role(Member.Role.USER) + .build(); + ReflectionTestUtils.setField(naverMember, "memberId", 20L); + + // OAuth2 attributes 생성 (카카오) + java.util.Map kakaoAttributes = new java.util.HashMap<>(); + kakaoAttributes.put("id", "kakao123"); + + // OAuth2 attributes 생성 (네이버) + java.util.Map naverAttributes = new java.util.HashMap<>(); + naverAttributes.put("id", "naver456"); + + // PrincipalDetails 생성 (MemberAdapter와 attributes 필요) + kakaoPrincipalDetails = new PrincipalDetails(kakaoMember, kakaoAttributes); + naverPrincipalDetails = new PrincipalDetails(naverMember, naverAttributes); + } + + @Test + @DisplayName("카카오 OAuth2 로그인 성공 - JWT 토큰 생성 및 쿠키 설정") + void onAuthenticationSuccess_Kakao() throws Exception { + // given + log.info("=== 카카오 OAuth2 로그인 성공 테스트 시작 ==="); + String accessToken = "kakaoAccessToken"; + String refreshToken = "kakaoRefreshToken"; + + given(authentication.getPrincipal()).willReturn(kakaoPrincipalDetails); + given(tokenProvider.generateAccessToken(kakaoMember)).willReturn(accessToken); + given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn(refreshToken); + log.info("Mock 설정 완료: 카카오 회원, 토큰 생성 준비"); + + // when + log.info("OAuth2 로그인 성공 핸들러 호출 중..."); + oauth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + log.info("OAuth2 로그인 성공 처리 완료"); + + // then + log.info("검증 시작: 토큰 생성 및 쿠키 설정 확인"); + verify(tokenProvider).generateAccessToken(kakaoMember); + log.info("카카오 회원 액세스 토큰 생성 호출 확인"); + verify(tokenProvider).generateRefreshToken(kakaoMember); + log.info("카카오 회원 리프레시 토큰 생성 호출 확인"); + verify(cookieUtil).setTokenCookies(response, accessToken, refreshToken); + log.info("쿠키에 토큰 설정 호출 확인"); + log.info("=== 카카오 OAuth2 로그인 성공 테스트 완료 ==="); + } + + @Test + @DisplayName("네이버 OAuth2 로그인 성공 - JWT 토큰 생성 및 쿠키 설정") + void onAuthenticationSuccess_Naver() throws Exception { + // given + log.info("=== 네이버 OAuth2 로그인 성공 테스트 시작 ==="); + String accessToken = "naverAccessToken"; + String refreshToken = "naverRefreshToken"; + + given(authentication.getPrincipal()).willReturn(naverPrincipalDetails); + given(tokenProvider.generateAccessToken(naverMember)).willReturn(accessToken); + given(tokenProvider.generateRefreshToken(naverMember)).willReturn(refreshToken); + log.info("Mock 설정 완료: 네이버 회원, 토큰 생성 준비"); + + // when + log.info("OAuth2 로그인 성공 핸들러 호출 중..."); + oauth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + log.info("OAuth2 로그인 성공 처리 완료"); + + // then + log.info("검증 시작: 토큰 생성 및 쿠키 설정 확인"); + verify(tokenProvider).generateAccessToken(naverMember); + log.info("네이버 회원 액세스 토큰 생성 호출 확인"); + verify(tokenProvider).generateRefreshToken(naverMember); + log.info("네이버 회원 리프레시 토큰 생성 호출 확인"); + verify(cookieUtil).setTokenCookies(response, accessToken, refreshToken); + log.info("쿠키에 토큰 설정 호출 확인"); + log.info("=== 네이버 OAuth2 로그인 성공 테스트 완료 ==="); + } + + @Test + @DisplayName("OAuth2 로그인 성공 후 리다이렉트 URL 확인") + void onAuthenticationSuccess_RedirectUrl() throws Exception { + // given + log.info("=== OAuth2 로그인 성공 후 리다이렉트 테스트 시작 ==="); + String accessToken = "testAccessToken"; + String refreshToken = "testRefreshToken"; + + given(authentication.getPrincipal()).willReturn(kakaoPrincipalDetails); + given(tokenProvider.generateAccessToken(kakaoMember)).willReturn(accessToken); + given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn(refreshToken); + + // when + oauth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + log.info("리다이렉트 URL 검증: http://localhost:8080/api/auth/oauth2/callback/success"); + // 실제 리다이렉트는 내부에서 sendRedirect로 처리되므로, + // 토큰 생성 및 쿠키 설정이 정상적으로 완료되었는지만 확인 + verify(cookieUtil).setTokenCookies(response, accessToken, refreshToken); + log.info("=== OAuth2 로그인 성공 후 리다이렉트 테스트 완료 ==="); + } + + @Test + @DisplayName("OAuth2 로그인 성공 - Redis에 토큰 저장 확인") + void onAuthenticationSuccess_RedisTokenStorage() throws Exception { + // given + log.info("=== OAuth2 로그인 성공 - Redis 토큰 저장 테스트 시작 ==="); + String accessToken = "redisTestAccessToken"; + String refreshToken = "redisTestRefreshToken"; + + given(authentication.getPrincipal()).willReturn(kakaoPrincipalDetails); + given(tokenProvider.generateAccessToken(kakaoMember)).willReturn(accessToken); + given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn(refreshToken); + log.info("Mock 설정 완료: 토큰 생성 시 Redis 저장 포함"); + + // when + log.info("OAuth2 로그인 성공 핸들러 호출 중..."); + oauth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + log.info("검증: TokenProvider의 generateAccessToken 호출 시 Redis에 자동 저장됨"); + verify(tokenProvider).generateAccessToken(kakaoMember); + log.info("검증: TokenProvider의 generateRefreshToken 호출 시 Redis에 자동 저장됨"); + verify(tokenProvider).generateRefreshToken(kakaoMember); + log.info("=== OAuth2 로그인 성공 - Redis 토큰 저장 테스트 완료 ==="); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 951e1037..bad18292 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -42,6 +42,14 @@ spring: redis: port: 6370 # 기본 포트와 충돌 방지 + ai: + vectorstore: + qdrant: + host: localhost + port: 6334 + collection-name: legal_cases + vector-size: 1536 + security: oauth2: client: @@ -50,11 +58,13 @@ spring: client-id: test-kakao-client-id client-secret: test-kakao-client-secret client-name: Kakao + redirect-uri: http://localhost:8080/login/oauth2/code/kakao scope: profile_nickname, account_email naver: client-id: test-naver-client-id client-secret: test-naver-client-secret client-name: Naver + redirect-uri: http://localhost:8080/login/oauth2/code/naver scope: profile, email provider: kakao: @@ -79,7 +89,14 @@ logging: com.ai.lawyer: DEBUG custom: + cors: + allowed-origins: http://localhost:3000 jwt: secretKey: test-secret-key-for-local-testing-only accessToken: - expirationSeconds: 3600 \ No newline at end of file + expirationSeconds: 3600 + oauth2: + redirect-url: http://localhost:3000/oauth/callback + failure-url: http://localhost:3000/oauth/callback?error=true + frontend: + url: http://localhost:3000 \ No newline at end of file