From 201cde2b6afa16b8d91f41f0e4e3ee17eb4c90b2 Mon Sep 17 00:00:00 2001 From: kannikii Date: Sun, 31 May 2026 19:59:34 +0900 Subject: [PATCH] =?UTF-8?q?mission:=209=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 8196 -> 10244 bytes build.gradle | 10 ++ src/.DS_Store | Bin 0 -> 10244 bytes src/main/.DS_Store | Bin 0 -> 6148 bytes .../member/controller/MemberController.java | 11 ++- .../member/converter/MemberConverter.java | 3 +- .../domain/member/service/MemberService.java | 42 +++++++-- .../mission/controller/MissionController.java | 21 ++--- .../review/controller/ReviewController.java | 13 ++- .../umc10th/global/config/SecurityConfig.java | 69 ++++---------- .../advice/GeneralExceptionAdvice.java | 18 ++++ .../global/security/CustomAccessDenied.java | 33 +++---- .../global/security/CustomEntryPoint.java | 33 +++---- .../security/SecurityResponseWriter.java | 34 ------- .../global/security/filter/JwtAuthFilter.java | 83 +++++++++++++++++ .../umc10th/global/security/util/JwtUtil.java | 86 ++++++++++++++++++ src/main/resources/application-dev.yml | 5 +- 17 files changed, 309 insertions(+), 152 deletions(-) create mode 100644 src/.DS_Store create mode 100644 src/main/.DS_Store delete mode 100644 src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java create mode 100644 src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java create mode 100644 src/main/java/com/example/umc10th/global/security/util/JwtUtil.java diff --git a/.DS_Store b/.DS_Store index a811b15f3bfa98bdaf90709b74d41d8d86466870..6a42b3593c9201a7b6ec0d61d49a14bb88f46c11 100644 GIT binary patch literal 10244 zcmeHMU2GIp6uxKL(iu9?DYR0s1Dh^Hz%7&(X!%RF?N<41k!|U={4BFOBOREXDLb>< zVyS6N^Z`U;jM4ZPc=9h|qN2nXO%$moQ~;GuF_W;}x5L+t?V3DHx7?m)_NQZsae zj=%$f2LcZS9tb=Tc;NQ%0OoA^h-I7xb>M-(1Azywdw}i_AuNURW|fvorDdfD;{DEG z+|#_A+p1;vQLf8ztW=?`rS_V}U{a}Ub8JsDZ6nJJOx-}0{XM3wJAg-!D2f+W^o_qFQJP&Q&ry34y?y)Sq%tp$uiiP}nU3x5HL@vB$tc&AbuAq20=9%LD1$+yzUPF0X57+8k})w(EiMa;0M4e7Q>Q zLtomS`JicJdo?$0*t%&S?jAICufHd6x@O8Ux@;|DNEHx~#>=Z0F1ou$Rk^~1K|wW< zOPR-VrgvNkiz1sNq}Iy&b$q8?j!tLMMddt>gR?JFYt?;J(v~%_)_sE&>b-K8i;EW( zjVrE_>*cPa2m(oQc~7IfPVKg|lwnDV&gW^CH>y2(?te+y!8^CeRca#bWHPu!SK@sw zYMXkHPPL&ylm_U2>6pO1>iBk)O#`@&^FSf>M|bWl#x=U@1gk9W+5RY=Bni zgx#?gR2|*K%2#zqs7a$aZ z?EvQswQw9;leP~GHvjrhICtYn zzfZ++KAyEL$3Bd`6!}HO#i&hBy4=2#lcT>H>+qU5Zm0vy1Mm#p!3vTfWdi3Ou%FI!RnIq-5`M3Pffd78k7M%Zs N^MB!7{J%Q?{|(nHnWF#z delta 1348 zcmeH`OKc5M7{|}Qt#(EaJ*AKPnlx2Xm7CJgc*LVx6;YHv(ubza+&hO3_s)IH?E{rI z!p1W<6C~bNL}O7}#Kr<`btO?dArTf>Sc;u9ckZeZR(2+t`R4z9-+AQs&v$a~$r=|S z1hsuy6(RPoBwjnR++?gMSy@_`W!Hr!WEDw}-oStv4TQ#GmvWLL3m)c3S<+&=BZu^n zFbR?>r_1uNsl2(l5?6T!?Xt$a$9z4E^_jehht!1L&$p=wZno>eh|#Pc<<pVDNZ4yR%lY zR(iZBwHiE)^X0OdRRW}Vtk~kh;!!eAUXn@jmb@ol$anG+KsIFSwZ~nz4^3!B7kZ)N z5KIi>D2`zWr*Il)aSrEk8P{sRu&5bwJk=y&b9<0>;>Q#{8DOkyex_Z>dtE512aW{Am2J-gFU1ZzRD?42_k z{mpSB6fq8pGLo^G{7k3al`CSsJ&a?qADLF((tVQH&>m4@_0WsU+dIujJW&@3^@WW; Nj?{mz{*_l#zW`8iMFs!> diff --git a/build.gradle b/build.gradle index 0e60733..e4d4028 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,16 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..181f2f2e0f6fe9660bd8242189d2caab19e53dbb GIT binary patch literal 10244 zcmeHMU2GIp6uxJ=&>1?=0a_{8kxdsO;1)^?wEQL8cB_=XBHPk!`B`RnMmjJ%Q+8&z z#ZuFl7!weUPa1#W$-jt+iV|NmQ3QSR2Td^Q14g61Xo4>)51xDPY}vNxgE2;MZgTIr z=bm%!z2}>AX7=7Cgg{$fUr9)q5F%owRFSc{PN8yIrxd{-p>}}wBt7H~rY$Fp{Mjkm z!9ajOfIxsifIxsifWUtN0sLmuB35u3v;hJE0s#W!2+;RK7%QbQpN?_LULDx5BLLD6 z4zq%Ng?5mJeH!!W7^f(~0^AfSHwFKQ0o)wN!@ge3r(>LQa{~V11N@c2KcN6y9p?`- zbpkO?gEl}QKwv5Y^z0rYS)vh>*rn(9^q^(BUPHs@Ae4osO)oE($}7tECI+0Lgr|9V zw@u6Krd+q)wE`GR1&KrE7tE!9kQ~vbBwzH{%e5DoaEB+Qh%%i zD-#(fJIHk4Xy*i(TWIHZ6=jK{uV47Siqeb zDW%dy=Q}fwy(4E(d2hzC@>$zWDYG-0mC0M0XS7;Y!o1hOu}U|Sb1Z9zlPyvH_meUvO)! zs&a*E21V6mK5ZV(o8A#6B8qH{uv#bY(Q!}P9G$MBi^>_C2UlOL)~UOxq&;U~tNR8^ z)jQ;F7o8UsjVmsf8|3ao7#@=1@{uNawc2B8X~U8fov+g(uTy&q9DhmK!Us3X)oK!- zaTZ;4B|g@wwyXQ-vRgIR>l!p{x}^(Tb|=dE-2p9UbPZv6`a9Z;qwJC5I5q0fP0wLM zL}PoMs`k<8T6LZIu%KpsrJ7KCMCB7Pr6{IdFfk?YvGU9iBgID(i7n&Z$UB*ntjBiVMR*lngER03 zybTxN1Naa=h0E{_d<);fPw*T34p)U~!aSiym@m``QK4Q~E;I`*!a8BSuwCd9l7c22 z6dYlgJ0KL(a4+YJt#AZ;HlES6kV)wj$GFJ87{ zRm;ZK@gU?;TMX3F@MkeUAyZh)vzV9^uNPSs%C{{nrrotfzk1U&dBRGNSKI9$Y*bhAxjuj zTO04dX)Y+@KNaPd$Yt_9`I%hBD4zv$p$2Y;28``C*aX{IbSIz(_CX5vLmG6*f(;HF z#yCF;qi`RL!TlKJ4`G}?0*}HI@FYBivHl!94==z=@G?gFn{XE1f_LFPI0xtbnEw(Z z{ztenDT=ohQvWy*A zOt6{oM*uSPI(`XOs_Wl&uzt)R8{?FjqYg7i`Y-(%z;EIH{#9FW{twRoH~9Jg5473d A)c^nh literal 0 HcmV?d00001 diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..302790bf84710ece12035b669e4c85014ec4d983 GIT binary patch literal 6148 zcmeHKJx;?=47N*#R4g4B7#MN@mfj##L1IMe1^S2hQKM9#GNR0I3QoYv#LB=OxCs)U zpRJOogpLSRw&eG{_$P6`L~%?+JX$Y?L<1s9p@O|WmJSiWXiIu#5hu$zmY2ivyee0% zNH_df2ISc#bVF0Rr)l*3`0SwRE32ZIEvp%9>GkvR%j4_&N$g*2^{=iP??!TtRO-_$ zUC

w8m~O`=|ms!W-HZ)vV>?;qB?`Gt|;?zOwqMYI|fbrL@IY8oftt!TVz8#e zo>^ROSSxBeu?-(=S7w_~I9wg)&q6qHuIQ^X;0(kJ?CIe^`v2+g^M9P=N6vsVuu}|h zQjCicZpnIU>*l1_X3z;#MB-}2O$s)l6(d$!@gXz_?3rwUxnZpc3&ei}0u8=61AofE EH(SI|#Q*>R literal 0 HcmV?d00001 diff --git a/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index e73876a..72acb55 100644 --- a/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -12,6 +12,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import com.example.umc10th.global.security.AuthMember; +import org.springframework.security.core.annotation.AuthenticationPrincipal; @Tag(name = "Member", description = "회원 관련 API") @RestController @@ -40,9 +42,8 @@ public ApiResponse login( @Operation(summary = "마이페이지 조회", description = "내 정보를 조회한다.") @GetMapping("/my") public ApiResponse getMyPage( - @RequestHeader("Authorization") String token) { - Long userId = 1L; // TODO: token에서 userId 추출 - MemberResDTO.MyPage result = memberService.getMyPage(userId); + @AuthenticationPrincipal AuthMember authMember) { + MemberResDTO.MyPage result = memberService.getMyPage(authMember.getId()); return ApiResponse.onSuccess(MemberSuccessCode.MEMBER_FETCH_OK, result); } @@ -64,10 +65,10 @@ public ApiResponse getMyPage( "JWT 도입 전 임시로 Body의 memberId를 사용한다.") @GetMapping("/missions/in-progress") public ApiResponse> getMyInProgressMissions( - @Valid @RequestBody MemberReqDTO.MyMissions request, + @AuthenticationPrincipal AuthMember authMember, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size) { - OffsetPage result = memberService.getMyInProgressMissions(request.memberId(), + OffsetPage result = memberService.getMyInProgressMissions(authMember.getId(), page, size); return ApiResponse.onSuccess(MemberSuccessCode.MEMBER_MISSION_LIST_OK, result); } diff --git a/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index e9d680e..b8f6e6d 100644 --- a/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -7,9 +7,8 @@ import com.example.umc10th.domain.member.entity.enums.SocialType; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.MissionParticipation; -import org.springframework.data.domain.Page; -import java.util.List; +import java.util.UUID; public class MemberConverter { diff --git a/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 5be179c..7612890 100644 --- a/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -12,6 +12,9 @@ import com.example.umc10th.domain.mission.repository.MissionParticipationRepository; import com.example.umc10th.domain.review.repository.ReviewRepository; import com.example.umc10th.global.apiPayload.dto.OffsetPage; +import com.example.umc10th.global.security.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; +import org.springframework.security.crypto.password.PasswordEncoder; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -31,19 +34,44 @@ public class MemberService { private final MemberRepository memberRepository; private final ReviewRepository reviewRepository; private final MissionParticipationRepository missionParticipationRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + @Transactional public MemberResDTO.JoinResult join(MemberReqDTO.Join request) { - // TODO: Repository 연결 - return MemberResDTO.JoinResult.builder() - .memberId(1L) - .createdAt(LocalDateTime.now()) - .build(); + // 이메일 중복 체크 (탈퇴하지 않은 회원 기준) + if (memberRepository.existsByEmailAndDeletedAtIsNull(request.email())) { + throw new MemberException(MemberErrorCode.DUPLICATE_EMAIL); + } + // 비밀번호 BCrypt 해싱 (솔트 자동 생성 + 해시에 포함) + String encodedPassword = passwordEncoder.encode(request.password()); + + // 컨버터로 엔티티 생성 (해싱된 비밀번호 전달) + Member member = MemberConverter.toMember(request, encodedPassword); + + // DB 저장 + Member saved = memberRepository.save(member); + + // 응답 DTO 변환 후 반환 + return MemberConverter.toJoinResult(saved); } public MemberResDTO.LoginResult login(MemberReqDTO.Login request) { - // TODO: Repository 연결 + // 1. 이메일로 회원 조회 (없으면 비밀번호 불일치와 동일 에러로 처리 → 이메일 존재 여부 노출 방지) + Member member = memberRepository.findByEmailAndDeletedAtIsNull(request.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_PASSWORD)); + + // 2. 입력 비밀번호(평문)와 저장된 해시 비교 + if (!passwordEncoder.matches(request.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID_PASSWORD); + } + + // 3. 인증 통과 → JWT 발급 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member)); + + // 4. 응답 DTO return MemberResDTO.LoginResult.builder() - .accessToken("dummy-token") + .accessToken(accessToken) .tokenType("Bearer") .build(); } diff --git a/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java b/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java index f0830b7..3041cf3 100644 --- a/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java +++ b/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java @@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import com.example.umc10th.global.security.AuthMember; +import org.springframework.security.core.annotation.AuthenticationPrincipal; @Tag(name = "Mission", description = "미션 관련 API") @RestController @@ -20,45 +22,42 @@ public class MissionController { @Operation(summary = "미션 진행률 조회") @GetMapping("/progress") public ApiResponse getProgress( - @RequestHeader("Authorization") String token + @AuthenticationPrincipal AuthMember authMember ) { Long userId = 1L; - MissionResDTO.Progress result = missionService.getProgress(userId); + MissionResDTO.Progress result = missionService.getProgress(authMember.getId()); return ApiResponse.onSuccess(MissionSuccessCode.MISSION_PROGRESS_OK, result); } @Operation(summary = "홈 미션 목록 조회") @GetMapping public ApiResponse getMissions( - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal AuthMember authMember, @RequestParam Long areaId, @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "10") Integer size ) { - Long userId = 1L; - MissionResDTO.MissionList result = missionService.getMissions(userId,areaId, page, size); + MissionResDTO.MissionList result = missionService.getMissions(authMember.getId(),areaId, page, size); return ApiResponse.onSuccess(MissionSuccessCode.MISSION_LIST_OK, result); } @Operation(summary = "미션 도전하기") @PostMapping("/{missionId}/participate") public ApiResponse participate( - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal AuthMember authMember, @PathVariable Long missionId ) { - Long userId = 1L; - MissionResDTO.ParticipateResult result = missionService.participate(userId, missionId); + MissionResDTO.ParticipateResult result = missionService.participate(authMember.getId(), missionId); return ApiResponse.onSuccess(MissionSuccessCode.MISSION_PARTICIPATE_OK, result); } @Operation(summary = "미션 성공 처리") @PatchMapping("/{missionId}/complete") public ApiResponse complete( - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal AuthMember authMember, @PathVariable Long missionId ) { - Long userId = 1L; - MissionResDTO.CompleteResult result = missionService.complete(userId, missionId); + MissionResDTO.CompleteResult result = missionService.complete(authMember.getId(), missionId); return ApiResponse.onSuccess(MissionSuccessCode.MISSION_COMPLETE_OK, result); } } \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index ac3e92a..fa027bc 100644 --- a/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -7,11 +7,15 @@ import com.example.umc10th.domain.review.service.ReviewService; import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.apiPayload.dto.CursorPage; +import com.example.umc10th.global.security.AuthMember; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import com.example.umc10th.global.security.AuthMember; +import org.springframework.security.core.annotation.AuthenticationPrincipal; @Tag(name = "Review", description = "리뷰 관련 API") @RestController @@ -24,11 +28,10 @@ public class ReviewController { @Operation(summary = "리뷰 작성", description = "가게에 대한 리뷰를 작성한다.") @PostMapping public ApiResponse create( - @RequestHeader("Authorization") String token, + @AuthenticationPrincipal AuthMember authMember, @Valid @RequestBody ReviewReqDTO.Create request ) { - Long userId = 1L; - ReviewResDTO.CreateResult result = reviewService.create(userId, request); + ReviewResDTO.CreateResult result = reviewService.create(authMember.getId(), request); return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_CREATED, result); } @@ -39,14 +42,14 @@ public ApiResponse create( ) @GetMapping("/my") public ApiResponse> getMyReviews( - @Valid @RequestBody ReviewReqDTO.MyReviews request, + @AuthenticationPrincipal AuthMember authMember, @RequestParam(defaultValue = "ID") ReviewSortType sortBy, @RequestParam(required = false) Long cursorId, @RequestParam(required = false) Integer cursorRating, @RequestParam(defaultValue = "10") Integer size ) { CursorPage result = reviewService.getMyReviews( - request.memberId(), sortBy, cursorId, cursorRating, size + authMember.getId(), sortBy, cursorId, cursorRating, size ); return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_LIST_OK, result); } diff --git a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 18b7d48..67fa1b7 100644 --- a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -2,6 +2,8 @@ import com.example.umc10th.global.security.CustomAccessDenied; import com.example.umc10th.global.security.CustomEntryPoint; +import com.example.umc10th.global.security.filter.JwtAuthFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -9,97 +11,62 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor // 빈 주입 public class SecurityConfig { - // ==================================================================== - // Public URI 목록 - 로그인 없이 접근 가능 - // ==================================================================== + // @Component로 등록된 빈들을 주입 + private final CustomEntryPoint customEntryPoint; + private final CustomAccessDenied customAccessDenied; + private final JwtAuthFilter jwtAuthFilter; // JWT 필터 주입 + private static final String[] PUBLIC_URIS = { - // 회원가입 (public) "/api/members/join", - - // 로그인 (자체 로그인 API + 시큐리티 폼 로그인 진입점) "/api/members/login", - "/login", - - // Swagger "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", - - // 정적 리소스 "/", "/index.html", "/favicon.ico", - - // H2 콘솔 "/h2-console/**" }; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - // CSRF 비활성화 (JWT 기반 API → 쿠키 자동 전송 없음 → CSRF 공격 불가) .csrf(csrf -> csrf.disable()) - - // H2 콘솔 화면이 iframe 내부에서 렌더링되므로 SAMEORIGIN 허용 .headers(headers -> headers .frameOptions(frame -> frame.sameOrigin())) - // 세션 정책 - 폼 로그인은 세션을 쓰지만, - // 9주차 JWT 적용 시 STATELESS로 바꿀 예정. 일단 IF_REQUIRED 유지. + // 세션을 쓰지 않는 STATELESS로 전환 (토큰 기반) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // URL별 접근 권한 설정 .authorizeHttpRequests(auth -> auth .requestMatchers(PUBLIC_URIS).permitAll() .anyRequest().authenticated() ) - // 폼 로그인 활성화 (JWT로 교체 예정) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) + // formLogin / logout 제거 (세션 기반 인증을 더 이상 쓰지 않음) - // 로그아웃 설정 - .logout(logout -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/") - .permitAll() - ) + // JWT 필터를 시큐리티 필터 체인에 등록 + // UsernamePasswordAuthenticationFilter 앞에 우리 필터를 끼운다 + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) - // 인증/인가 실패 시 응답 통일 핸들러 등록 + // 주입받은 빈으로 통일 응답 처리 .exceptionHandling(exception -> exception - .authenticationEntryPoint(authenticationEntryPoint()) - .accessDeniedHandler(accessDeniedHandler()) + .authenticationEntryPoint(customEntryPoint) + .accessDeniedHandler(customAccessDenied) ); return http.build(); } - // ==================================================================== - // 빈 등록 - // ==================================================================== - @Bean public PasswordEncoder passwordEncoder() { - // BCrypt: 솔트 자동 생성 + 해시값에 솔트 포함 → 별도 솔트 컬럼 불필요 return new BCryptPasswordEncoder(); } - - @Bean - public AuthenticationEntryPoint authenticationEntryPoint() { - return new CustomEntryPoint(); - } - - @Bean - public AccessDeniedHandler accessDeniedHandler() { - return new CustomAccessDenied(); - } } \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/exception/advice/GeneralExceptionAdvice.java b/src/main/java/com/example/umc10th/global/exception/advice/GeneralExceptionAdvice.java index 9b61d28..e73d9d4 100644 --- a/src/main/java/com/example/umc10th/global/exception/advice/GeneralExceptionAdvice.java +++ b/src/main/java/com/example/umc10th/global/exception/advice/GeneralExceptionAdvice.java @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import java.util.HashMap; import java.util.Map; @@ -31,6 +33,22 @@ public ResponseEntity> handleProjectException(ProjectException .status(errorCode.getStatus()) .body(ApiResponse.onFailure(errorCode, null)); } + + // 2. 시큐리티 인증 실패 (401) + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException(AuthenticationException e) { + log.warn("인증 실패: {}", e.getMessage()); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + return ResponseEntity.status(code.getStatus()).body(ApiResponse.onFailure(code, null)); + } + + // 2. 시큐리티 인가 실패 (403) + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException e) { + log.warn("인가 실패: {}", e.getMessage()); + BaseErrorCode code = GeneralErrorCode.FORBIDDEN; + return ResponseEntity.status(code.getStatus()).body(ApiResponse.onFailure(code, null)); + } // 2. @Valid 검증 실패 (DTO에 붙인 @NotBlank 등) @ExceptionHandler(MethodArgumentNotValidException.class) diff --git a/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java b/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java index 4792ecd..dc1eefc 100644 --- a/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java +++ b/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java @@ -1,30 +1,27 @@ package com.example.umc10th.global.security; -import com.example.umc10th.global.apiPayload.code.BaseErrorCode; -import com.example.umc10th.global.apiPayload.code.status.GeneralErrorCode; +import jakarta.annotation.Nonnull; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; -import java.io.IOException; - -/** - * 인증은 됐지만 권한이 부족한 사용자가 자원에 접근했을 때 호출. - * → 403 Forbidden + ApiResponse 형식 JSON 응답 - */ -@Slf4j +@Component public class CustomAccessDenied implements AccessDeniedHandler { + private final HandlerExceptionResolver resolver; + + public CustomAccessDenied(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + @Override - public void handle( - HttpServletRequest request, - HttpServletResponse response, - AccessDeniedException accessDeniedException - ) throws IOException { - log.warn("인가 실패: {} - {}", request.getRequestURI(), accessDeniedException.getMessage()); - BaseErrorCode code = GeneralErrorCode.FORBIDDEN; - SecurityResponseWriter.writeErrorResponse(response, code); + public void handle(@Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response, + @Nonnull AccessDeniedException accessDeniedException) { + resolver.resolveException(request, response, null, accessDeniedException); } } \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java b/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java index f1d7686..bcbf9cf 100644 --- a/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java +++ b/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java @@ -1,30 +1,27 @@ package com.example.umc10th.global.security; -import com.example.umc10th.global.apiPayload.code.BaseErrorCode; -import com.example.umc10th.global.apiPayload.code.status.GeneralErrorCode; +import jakarta.annotation.Nonnull; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; -import java.io.IOException; - -/** - * 인증되지 않은 사용자가 보호된 자원에 접근했을 때 호출. - * → 401 Unauthorized + ApiResponse 형식 JSON 응답 - */ -@Slf4j +@Component public class CustomEntryPoint implements AuthenticationEntryPoint { + private final HandlerExceptionResolver resolver; + + public CustomEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + @Override - public void commence( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authException - ) throws IOException { - log.warn("인증 실패: {} - {}", request.getRequestURI(), authException.getMessage()); - BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; - SecurityResponseWriter.writeErrorResponse(response, code); + public void commence(@Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response, + @Nonnull AuthenticationException authException) { + resolver.resolveException(request, response, null, authException); } } \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java b/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java deleted file mode 100644 index bbe8026..0000000 --- a/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.umc10th.global.security; - -import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.BaseErrorCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletResponse; - -import java.io.IOException; - -/** - * 시큐리티 필터 단에서 발생한 예외를 ApiResponse 형식의 JSON으로 응답하는 공통 유틸. - * - * - CustomEntryPoint(401), CustomAccessDenied(403)에서 공통으로 사용 - * - GeneralExceptionAdvice는 필터 예외에 손이 닿지 않으므로 여기서 직접 직렬화 - */ -public class SecurityResponseWriter { - - private SecurityResponseWriter() {} - - private static final ObjectMapper objectMapper = new ObjectMapper(); - - public static void writeErrorResponse(HttpServletResponse response, BaseErrorCode code) - throws IOException { - // 1. 응답 Content-Type, HTTP 상태코드 설정 - response.setContentType("application/json;charset=UTF-8"); - response.setStatus(code.getStatus().value()); - - // 2. ApiResponse 형식으로 본문 객체 생성 - ApiResponse errorResponse = ApiResponse.onFailure(code, null); - - // 3. JSON 직렬화 후 응답 OutputStream에 쓰기 - objectMapper.writeValue(response.getOutputStream(), errorResponse); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 0000000..936ed31 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,83 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.global.security.CustomUserDetailsService; +import com.example.umc10th.global.security.util.JwtUtil; +import jakarta.annotation.Nonnull; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +/** + * 요청마다 한 번 실행되며(OncePerRequestFilter), Authorization 헤더의 JWT를 검사한다. + * - 토큰 없음/Bearer 아님 → 그냥 다음 필터로 (이후 EntryPoint가 401 처리) + * - 유효한 토큰 → 인증 객체 생성 후 SecurityContext에 주입 + * - 잘못된 토큰(파싱/서명/만료 실패) → resolver로 예외 토스 → GeneralExceptionAdvice가 통일 응답 + */ +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final HandlerExceptionResolver resolver; + + public JwtAuthFilter(JwtUtil jwtUtil, + CustomUserDetailsService customUserDetailsService, + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.jwtUtil = jwtUtil; + this.customUserDetailsService = customUserDetailsService; + this.resolver = resolver; + } + + @Override + protected void doFilterInternal( + @Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response, + @Nonnull FilterChain filterChain + ) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + + // 토큰이 없거나 Bearer 형식이 아니면 인증 시도 없이 통과 + // (보호된 자원이면 이후 시큐리티가 EntryPoint로 401 처리) + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = header.replace("Bearer ", ""); + + try { + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 → 사용자 조회 → 인증 객체 생성 + String email = jwtUtil.getEmail(token); + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities()); + // SecurityContext에 인증 객체 주입 → 이 요청은 인증된 상태가 됨 + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + // 토큰은 있는데 유효하지 않음 → 예외를 토스 (8주차 통일 응답 흐름 재사용) + throw new BadCredentialsException("유효하지 않은 토큰입니다."); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + // BadCredentialsException 등은 AuthenticationException의 하위라 + // GeneralExceptionAdvice의 handleAuthenticationException(401)이 잡는다. + SecurityContextHolder.clearContext(); + resolver.resolveException(request, response, null, + new BadCredentialsException("토큰 인증에 실패했습니다.", e)); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 0000000..f32a41a --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,86 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.global.security.AuthMember; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + // 시크릿 문자열을 HMAC-SHA 키 객체로 변환 (최소 32바이트 이상이어야 함) + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 - 인증된 사용자 정보로 토큰을 만든다 + public String createAccessToken(AuthMember member) { + return createToken(member, accessExpiration); + } + + // 토큰에서 이메일(subject) 추출. 파싱 실패 시 null + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + // 토큰 유효성 검증 (서명·만료 모두 확인). 유효하면 true + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 내부 로직 + private String createToken(AuthMember member, Duration expiration) { + Instant now = Instant.now(); + + // 권한 정보를 콤마로 join (ROLE_USER 등) + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) // 이메일을 subject로 + .claim("role", authorities) // 권한 + .claim("email", member.getUsername()) // 이메일 (편의용) + .issuedAt(Date.from(now)) // 발급 시각 + .expiration(Date.from(now.plus(expiration))) // 만료 시각 + .signWith(secretKey) // 서명 + .compact(); + } + + // 토큰 파싱 + 서명/만료 검증 → Claims 반환 (실패 시 JwtException) + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) // 서버 간 시계 오차 60초 허용 + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 5d829a7..2c163de 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,7 +1,7 @@ spring: datasource: driver-class-name: org.h2.Driver - url: jdbc:h2:mem:umc10th + url: jdbc:h2:~/h2/umc10th;MODE=MySQL username: sa password: @@ -19,4 +19,7 @@ spring: console: enabled: true path: /h2-console + settings: + web-allow-others: true +