Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Binary file added src/.DS_Store
Binary file not shown.
Binary file added src/main/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,9 +42,8 @@ public ApiResponse<MemberResDTO.LoginResult> login(
@Operation(summary = "마이페이지 조회", description = "내 정보를 조회한다.")
@GetMapping("/my")
public ApiResponse<MemberResDTO.MyPage> 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);
}

Expand All @@ -64,10 +65,10 @@ public ApiResponse<MemberResDTO.MyPage> getMyPage(
"JWT 도입 전 임시로 Body의 memberId를 사용한다.")
@GetMapping("/missions/in-progress")
public ApiResponse<OffsetPage<MemberResDTO.MissionListItem>> getMyInProgressMissions(
@Valid @RequestBody MemberReqDTO.MyMissions request,
@AuthenticationPrincipal AuthMember authMember,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
OffsetPage<MemberResDTO.MissionListItem> result = memberService.getMyInProgressMissions(request.memberId(),
OffsetPage<MemberResDTO.MissionListItem> result = memberService.getMyInProgressMissions(authMember.getId(),
page, size);
return ApiResponse.onSuccess(MemberSuccessCode.MEMBER_MISSION_LIST_OK, result);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,45 +22,42 @@ public class MissionController {
@Operation(summary = "미션 진행률 조회")
@GetMapping("/progress")
public ApiResponse<MissionResDTO.Progress> 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<MissionResDTO.MissionList> 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<MissionResDTO.ParticipateResult> 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<MissionResDTO.CompleteResult> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,11 +28,10 @@ public class ReviewController {
@Operation(summary = "리뷰 작성", description = "가게에 대한 리뷰를 작성한다.")
@PostMapping
public ApiResponse<ReviewResDTO.CreateResult> 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);
}

Expand All @@ -39,14 +42,14 @@ public ApiResponse<ReviewResDTO.CreateResult> create(
)
@GetMapping("/my")
public ApiResponse<CursorPage<ReviewResDTO.MyReviewItem>> 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<ReviewResDTO.MyReviewItem> result = reviewService.getMyReviews(
request.memberId(), sortBy, cursorId, cursorRating, size
authMember.getId(), sortBy, cursorId, cursorRating, size
);
return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_LIST_OK, result);
}
Expand Down
69 changes: 18 additions & 51 deletions src/main/java/com/example/umc10th/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,104 +2,71 @@

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;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +33,22 @@ public ResponseEntity<ApiResponse<Void>> handleProjectException(ProjectException
.status(errorCode.getStatus())
.body(ApiResponse.onFailure(errorCode, null));
}

// 2. 시큐리티 인증 실패 (401)
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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)
Expand Down
Loading