diff --git a/build.gradle b/build.gradle index 03cd742..1b4d44b 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' 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' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' 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 731557e..d1ad441 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 @@ -25,13 +25,7 @@ public class MemberController { public ApiResponse signUp( @Valid @RequestBody MemberReqDTO.SignUp request ) { - // 6주차에서 memberService.signUp(request)로 교체 - MemberResDTO.SignUp result = MemberResDTO.SignUp.builder() - .memberId(1L) - .nickname(request.nickname()) - .email(request.email()) - .build(); - + MemberResDTO.SignUp result = memberService.signUp(request); return ApiResponse.onSuccess(MemberSuccessCode.SIGN_UP, 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 4a69730..e01cfa2 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 @@ -1,15 +1,44 @@ package com.example.umc10th.domain.member.converter; +import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.Gender; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.global.dto.PageInfoDTO; +import com.example.umc10th.global.enums.Address; import org.springframework.data.domain.Page; import java.util.List; public class MemberConverter { + // 회원가입: 요청 DTO -> Member 엔티티 + public static Member toMember(MemberReqDTO.SignUp request, String encodedPassword) { + return Member.builder() + .name(request.name()) + .nickname(request.nickname()) + .email(request.email()) + .password(encodedPassword) + .phoneNumber(request.phoneNumber()) + .gender(Gender.valueOf(request.gender())) + .birth(request.birth()) + .address(Address.valueOf(request.address())) + .detailAddress(request.detailAddress()) + // socialType, profileUrl, point는 엔티티의 @Builder.Default로 자동 처리 + // (LOCAL, 기본 프로필 URL, 0) + .build(); + } + + // 회원가입: 저장된 Member 엔티티 -> 회원가입 응답 DTO + public static MemberResDTO.SignUp toSignupResponse(Member member) { + return MemberResDTO.SignUp.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .email(member.getEmail()) + .build(); + } + // 마이페이지 변환 public static MemberResDTO.MyPage toMyPage(Member member) { return MemberResDTO.MyPage.builder() diff --git a/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 87f3bfd..32075dd 100644 --- a/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -48,12 +48,17 @@ public class Member extends BaseEntity { @Column(name = "detail_address", nullable = false, length = 255) private String detailAddress; - @Column(name = "social_uid", nullable = false, length = 255) + @Column(name = "social_uid", length = 255) private String socialUid; @Enumerated(EnumType.STRING) @Column(name = "social_type", nullable = false) - private SocialType socialType; + @Builder.Default + private SocialType socialType = SocialType.LOCAL; + + // BCrypt 해시 결과는 60자 고정이지만, 알고리즘 변경 대비 여유롭게 100자로 설정 + @Column(nullable = false, length = 100) + private String password; @Column(nullable = false) @Builder.Default @@ -66,8 +71,10 @@ public class Member extends BaseEntity { private String phoneNumber; // columnDefinition = "TEXT": 255자 이상의 대용량 텍스트 저장 가능 타입으로 지정 + // 기본 프로필 URL - 회원가입 시 따로 안 받으므로 기본값 박기 @Column(name = "profile_url", columnDefinition = "TEXT", nullable = false) - private String profileUrl; + @Builder.Default + private String profileUrl = "https://default-profile.example.com/default.png"; // 연관 관계 @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE) diff --git a/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java b/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java index 4367db5..43e9e75 100644 --- a/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java +++ b/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java @@ -1,5 +1,6 @@ package com.example.umc10th.domain.member.enums; public enum SocialType { + LOCAL, // 폼 회원가입 (이메일/비밀번호) KAKAO, NAVER, APPLE, GOOGLE } diff --git a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index 9d62f9c..367b3e1 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -9,7 +9,11 @@ @RequiredArgsConstructor public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "회원을 찾을 수 없습니다."), - MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER409_1", "이미 존재하는 유저입니다."); + MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER409_1", "이미 존재하는 유저입니다."), + + // 회원가입 관련 에러 + FOOD_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_1", "유효하지 않은 선호 음식입니다."), + TERM_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "유효하지 않은 약관입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/example/umc10th/domain/member/repository/FoodRepository.java b/src/main/java/com/example/umc10th/domain/member/repository/FoodRepository.java new file mode 100644 index 0000000..56e69e4 --- /dev/null +++ b/src/main/java/com/example/umc10th/domain/member/repository/FoodRepository.java @@ -0,0 +1,10 @@ +package com.example.umc10th.domain.member.repository; + +import com.example.umc10th.domain.member.entity.Food; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FoodRepository extends JpaRepository { + Optional findByName(com.example.umc10th.domain.member.enums.Food name); +} diff --git a/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index a73b7c5..157519a 100644 --- a/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -3,7 +3,15 @@ import com.example.umc10th.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + // JpaRepository: Spring Data JPA가 제공하는 인터페이스로, 기본적인 CRUD 메서드를 자동으로 구현해준다. // : 이 Repository는 Member 엔터티를 관리하며, Member 엔터티의 기본 키(PK) 타입이 Long임을 나타낸다. public interface MemberRepository extends JpaRepository { + + // 회원가입 시 이메일 중복 체크용 + boolean existsByEmail(String email); + + // 로그인에서 사용자 이메일로 회원 조회 + Optional findByEmail(String email); } diff --git a/src/main/java/com/example/umc10th/domain/member/repository/TermRepository.java b/src/main/java/com/example/umc10th/domain/member/repository/TermRepository.java new file mode 100644 index 0000000..4157d32 --- /dev/null +++ b/src/main/java/com/example/umc10th/domain/member/repository/TermRepository.java @@ -0,0 +1,10 @@ +package com.example.umc10th.domain.member.repository; + +import com.example.umc10th.domain.member.entity.Term; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TermRepository extends JpaRepository { + Optional findByName(com.example.umc10th.domain.member.enums.Term name); +} 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 7cb5069..7a5ad3f 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 @@ -1,17 +1,27 @@ package com.example.umc10th.domain.member.service; import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; +import com.example.umc10th.domain.member.entity.Food; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.entity.Term; +import com.example.umc10th.domain.member.entity.mapping.MemberFood; +import com.example.umc10th.domain.member.entity.mapping.MemberTerm; import com.example.umc10th.domain.member.exception.MemberException; import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.FoodRepository; import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.domain.member.repository.TermRepository; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.repository.MemberMissionRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +34,71 @@ public class MemberService { private final MemberRepository memberRepository; private final MemberMissionRepository memberMissionRepository; + private final FoodRepository foodRepository; + private final TermRepository termRepository; + private final PasswordEncoder passwordEncoder; + + @PersistenceContext + private EntityManager em; // MemberFood/MemberTerm을 직접 persist하기 위해 + + // 회원가입 + @Transactional + public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp request) { + + // 1. 이메일 중복 체크 + if (memberRepository.existsByEmail(request.email())) { + throw new MemberException(MemberErrorCode.MEMBER_ALREADY_EXISTS); + } + + // 2. 비밀번호 BCrypt 인코딩 + String encodedPassword = passwordEncoder.encode(request.password()); + + // 3. Member 엔티티 생성 및 저장 + Member member = MemberConverter.toMember(request, encodedPassword); + Member savedMember = memberRepository.save(member); + + // 4. 선호 음식 매핑 저장 + for (String foodName: request.preferredFoods()) { + com.example.umc10th.domain.member.enums.Food foodEnum; + try { + foodEnum = com.example.umc10th.domain.member.enums.Food.valueOf(foodName); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.FOOD_NOT_FOUND); + } + + Food food = foodRepository.findByName(foodEnum) + .orElseThrow(() -> new MemberException(MemberErrorCode.FOOD_NOT_FOUND)); + + MemberFood memberFood = MemberFood.builder() + .member(savedMember) + .food(food) + .build(); + + em.persist(memberFood); + } + + // 5. 약관 동의 매핑 저장 + for (String termName: request.agreedTerms()) { + com.example.umc10th.domain.member.enums.Term termEnum; + try { + termEnum = com.example.umc10th.domain.member.enums.Term.valueOf(termName); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.TERM_NOT_FOUND); + } + + Term term = termRepository.findByName(termEnum) + .orElseThrow(() -> new MemberException(MemberErrorCode.TERM_NOT_FOUND)); + + MemberTerm memberTerm = MemberTerm.builder() + .member(savedMember) + .term(term) + .build(); + + em.persist(memberTerm); + } + + return MemberConverter.toSignupResponse(savedMember); + } // 마이페이지 public MemberResDTO.MyPage getMyPage(Long memberId) { diff --git a/src/main/java/com/example/umc10th/global/config/InitDataLoader.java b/src/main/java/com/example/umc10th/global/config/InitDataLoader.java new file mode 100644 index 0000000..a757e4a --- /dev/null +++ b/src/main/java/com/example/umc10th/global/config/InitDataLoader.java @@ -0,0 +1,54 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.domain.member.entity.Food; +import com.example.umc10th.domain.member.entity.Term; +import com.example.umc10th.domain.member.repository.FoodRepository; +import com.example.umc10th.domain.member.repository.TermRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; + +@Slf4j +@Component +@RequiredArgsConstructor +// CommandLineRunner: Spring이 제공하는 인터페이스 +// 이걸 구현하면 애플리케이션이 시작된 직후에 run() 메서드가 자동으로 호출된다. +public class InitDataLoader implements CommandLineRunner { + + private final FoodRepository foodRepository; + private final TermRepository termRepository; + + @Override + @Transactional + public void run(String... args) { + seedFoods(); + seedTerms(); + } + + private void seedFoods() { + // enum의 모든 값을 순회하면서, DB에 없는 것만 INSERT + Arrays.stream(com.example.umc10th.domain.member.enums.Food.values()) + .filter(foodEnum -> foodRepository.findByName(foodEnum).isEmpty()) + .forEach(foodEnum -> { + foodRepository.save(Food.builder() + .name(foodEnum) + .build()); + log.info("[InitDataLoader] Food 시드: {}", foodEnum); + }); + } + + private void seedTerms() { + Arrays.stream(com.example.umc10th.domain.member.enums.Term.values()) + .filter(termEnum -> termRepository.findByName(termEnum).isEmpty()) + .forEach(termEnum -> { + termRepository.save(Term.builder() + .name(termEnum) + .build()); + log.info("[InitDataLoader] Term 시드: {}", termEnum); + }); + } +} diff --git a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 0000000..130840a --- /dev/null +++ b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.global.security.CustomAccessDenied; +import com.example.umc10th.global.security.CustomEntryPoint; +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.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomEntryPoint customEntryPoint; + private final CustomAccessDenied customAccessDenied; + + private final String[] allowUris = { + // Swagger 허용 + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/auth/**", + + // 폼 로그인 페이지 자체 + "/login", + "/login/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customEntryPoint) + .accessDeniedHandler(customAccessDenied) + ) + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/AuthMember.java b/src/main/java/com/example/umc10th/global/security/AuthMember.java new file mode 100644 index 0000000..91328dc --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/AuthMember.java @@ -0,0 +1,35 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + // 지금은 단일 권한 ROLE_USER만 + // 나중에 ADMIN 등 추가 시 여기서 분기 + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } +} diff --git a/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java b/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java new file mode 100644 index 0000000..09d9640 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDenied implements AccessDeniedHandler { + + private final SecurityResponseWriter responseWriter; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + responseWriter.write(response, GeneralErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java b/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java new file mode 100644 index 0000000..8b6d29b --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomEntryPoint implements AuthenticationEntryPoint { + + private final SecurityResponseWriter responseWriter; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + responseWriter.write(response, GeneralErrorCode.UNAUTHORIZED); + } +} diff --git a/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java b/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..6a77ff6 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException( + "해당 이메일의 회원을 찾을 수 없습니다: " + email + )); + return new AuthMember(member); + } +} diff --git a/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java b/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java new file mode 100644 index 0000000..e09fca5 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/SecurityResponseWriter.java @@ -0,0 +1,26 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class SecurityResponseWriter { + + private ObjectMapper objectMapper; + + public void write(HttpServletResponse response, BaseErrorCode code) throws IOException { + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +}