diff --git a/build.gradle b/build.gradle index bba05d2..cef5560 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,10 @@ dependencies { // oauth2 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework:spring-context-support' } tasks.named('test') { diff --git a/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java b/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java index bd22056..9a4d0d7 100644 --- a/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java +++ b/src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java @@ -33,7 +33,10 @@ public enum ErrorStatus implements BaseErrorCode { MUSIC_NOT_FOUND(HttpStatus.BAD_REQUEST, "MUSIC400", "음원을 찾을 수 없습니다."), - LIKE_NOT_FOUND(HttpStatus.BAD_REQUEST, "LIKE400", "해당 좋아요를 찾을 수 없습니다."); + LIKE_NOT_FOUND(HttpStatus.BAD_REQUEST, "LIKE400", "해당 좋아요를 찾을 수 없습니다."), + + EMAIL_SEND_ERROR(HttpStatus.BAD_REQUEST, "EMAIL400", "메일 발송에 실패하였습니다."), + EMAIL_CODE_ERROR(HttpStatus.BAD_REQUEST, "EMAIL401", "유효한 코드가 아닙니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/umc/codeplay/config/security/CustomUserDetails.java b/src/main/java/umc/codeplay/config/security/CustomUserDetails.java new file mode 100644 index 0000000..5c68da7 --- /dev/null +++ b/src/main/java/umc/codeplay/config/security/CustomUserDetails.java @@ -0,0 +1,52 @@ +package umc.codeplay.config.security; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + private final String email; + private final String password; + private final Collection authorities; + + @Override + public String getUsername() { + return email; // username대신 email 사용 + } + + @Override + public String getPassword() { + return password; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/umc/codeplay/config/security/CustomUserDetailsService.java b/src/main/java/umc/codeplay/config/security/CustomUserDetailsService.java index 5cc649c..16ea241 100644 --- a/src/main/java/umc/codeplay/config/security/CustomUserDetailsService.java +++ b/src/main/java/umc/codeplay/config/security/CustomUserDetailsService.java @@ -1,5 +1,8 @@ package umc.codeplay.config.security; +import java.util.Collections; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -19,15 +22,21 @@ public class CustomUserDetailsService implements UserDetailsService { private final MemberRepository memberRepository; @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Member member = memberRepository - .findByEmail(username) + .findByEmail(email) .orElseThrow(() -> new GeneralHandler(ErrorStatus.MEMBER_NOT_FOUND)); - return org.springframework.security.core.userdetails.User.withUsername(member.getEmail()) - .password(member.getPassword()) - .roles(member.getRole().name()) - .build(); + // return + // org.springframework.security.core.userdetails.User.withUsername(member.getEmail()) + // .password(member.getPassword()) + // .roles(member.getRole().name()) + // .build(); + + return new CustomUserDetails( + member.getEmail(), + member.getPassword(), + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); } } diff --git a/src/main/java/umc/codeplay/controller/AuthController.java b/src/main/java/umc/codeplay/controller/AuthController.java index 3dcb448..a12c387 100644 --- a/src/main/java/umc/codeplay/controller/AuthController.java +++ b/src/main/java/umc/codeplay/controller/AuthController.java @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.stream.Collectors; +import jakarta.mail.MessagingException; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -24,6 +25,7 @@ import umc.codeplay.dto.MemberRequestDTO; import umc.codeplay.dto.MemberResponseDTO; import umc.codeplay.jwt.JwtUtil; +import umc.codeplay.service.EmailService; import umc.codeplay.service.MemberService; @RestController @@ -35,6 +37,7 @@ public class AuthController { private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; private final MemberService memberService; + private final EmailService emailService; @PostMapping("/login") public ApiResponse login( @@ -104,4 +107,25 @@ public ApiResponse refresh( throw new GeneralHandler(ErrorStatus.INVALID_REFRESH_TOKEN); } } + + // 비밀번호 찾기 및 변경. 이메일 인증 + @PostMapping("/password/reset/request") + public ApiResponse resetPasswordRequest( + @RequestBody MemberRequestDTO.ResetPasswordDTO request) throws MessagingException { + emailService.sendCode(request.getEmail()); + return ApiResponse.onSuccess("메일로 인증번호가 전송되었습니다."); + } + + // 비밀번호 찾기 및 변경. 인증 코드 확인 + @PostMapping("/password/reset/verify") + public ApiResponse resetPasswordVerify( + @RequestBody MemberRequestDTO.CheckVerificationCodeDTO request) { + boolean isValid = emailService.verifyCode(request.getEmail(), request.getCode()); + if (isValid) { + return ApiResponse.onSuccess("인증에 성공하였습니다."); + // 이후에 비밀번호 변경 페이지 연결해 주어야 함. + } else { + throw new GeneralHandler(ErrorStatus.EMAIL_CODE_ERROR); + } + } } diff --git a/src/main/java/umc/codeplay/controller/MemberController.java b/src/main/java/umc/codeplay/controller/MemberController.java new file mode 100644 index 0000000..2e01640 --- /dev/null +++ b/src/main/java/umc/codeplay/controller/MemberController.java @@ -0,0 +1,37 @@ +package umc.codeplay.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.ApiResponse; +import umc.codeplay.config.security.CustomUserDetails; +import umc.codeplay.converter.MemberConverter; +import umc.codeplay.domain.Member; +import umc.codeplay.dto.MemberRequestDTO; +import umc.codeplay.dto.MemberResponseDTO; +import umc.codeplay.service.MemberService; + +@RestController +@RequestMapping("/member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + private final MemberConverter memberConverter; + + @PutMapping("/update") + public ApiResponse updateMember( + @AuthenticationPrincipal + CustomUserDetails + userDetails, // 현재 로그인된 사용자 정보, email로 조회하기 위해 customUserDetails 사용 + @RequestBody MemberRequestDTO.UpdateMemberDTO requestDto) { + + Member updatedMember = memberService.updateMember(userDetails.getUsername(), requestDto); + MemberResponseDTO.UpdateResultDTO responseDto = + memberConverter.toUpdateResultDTO(updatedMember); + + return ApiResponse.onSuccess(responseDto); + } +} diff --git a/src/main/java/umc/codeplay/controller/MusicController.java b/src/main/java/umc/codeplay/controller/MusicController.java new file mode 100644 index 0000000..bcb8c06 --- /dev/null +++ b/src/main/java/umc/codeplay/controller/MusicController.java @@ -0,0 +1,26 @@ +package umc.codeplay.controller; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.ApiResponse; +import umc.codeplay.service.MusicService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/music") +public class MusicController { + + private final MusicService musicService; + + @DeleteMapping("/{musicId}") + public ApiResponse delete(@PathVariable Long musicId) { + + musicService.deleteMusic(musicId); + return ApiResponse.onSuccess(musicId); + } +} diff --git a/src/main/java/umc/codeplay/converter/MemberConverter.java b/src/main/java/umc/codeplay/converter/MemberConverter.java index 70f43d3..75c735c 100644 --- a/src/main/java/umc/codeplay/converter/MemberConverter.java +++ b/src/main/java/umc/codeplay/converter/MemberConverter.java @@ -1,11 +1,14 @@ package umc.codeplay.converter; +import org.springframework.stereotype.Component; + import umc.codeplay.domain.Member; import umc.codeplay.domain.enums.Role; import umc.codeplay.domain.enums.SocialStatus; import umc.codeplay.dto.MemberRequestDTO; import umc.codeplay.dto.MemberResponseDTO; +@Component public class MemberConverter { public static Member toMember(MemberRequestDTO.JoinDto request) { @@ -32,4 +35,11 @@ public static MemberResponseDTO.LoginResultDTO toLoginResultDTO( .refreshToken(refreshToken) .build(); } + + public static MemberResponseDTO.UpdateResultDTO toUpdateResultDTO(Member member) { + return MemberResponseDTO.UpdateResultDTO.builder() + .email(member.getEmail()) + .profileUrl(member.getProfileUrl()) + .build(); + } } diff --git a/src/main/java/umc/codeplay/domain/enums/Role.java b/src/main/java/umc/codeplay/domain/enums/Role.java index 8645757..05b5d9f 100644 --- a/src/main/java/umc/codeplay/domain/enums/Role.java +++ b/src/main/java/umc/codeplay/domain/enums/Role.java @@ -1,6 +1,16 @@ package umc.codeplay.domain.enums; public enum Role { - ADMIN, - USER + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + private final String role; + + Role(String role) { + this.role = role; + } + + public String getRole() { + return role; + } } diff --git a/src/main/java/umc/codeplay/dto/EmailCodeDTO.java b/src/main/java/umc/codeplay/dto/EmailCodeDTO.java new file mode 100644 index 0000000..08e2027 --- /dev/null +++ b/src/main/java/umc/codeplay/dto/EmailCodeDTO.java @@ -0,0 +1,18 @@ +package umc.codeplay.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class EmailCodeDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class VerificationCode { + String code; + LocalDateTime expires; + } +} diff --git a/src/main/java/umc/codeplay/dto/MemberRequestDTO.java b/src/main/java/umc/codeplay/dto/MemberRequestDTO.java index cb1d051..bbcf034 100644 --- a/src/main/java/umc/codeplay/dto/MemberRequestDTO.java +++ b/src/main/java/umc/codeplay/dto/MemberRequestDTO.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import lombok.Getter; +import lombok.Setter; public class MemberRequestDTO { @@ -31,4 +32,26 @@ public static class LoginDto { @NotBlank(message = "비밀번호는 필수 입력값입니다.") String password; } + + @Getter + public static class ResetPasswordDTO { + @Email(message = "이메일 형식이 아닙니다.") + String email; + } + + @Getter + public static class CheckVerificationCodeDTO { + String email; + String code; + } + + @Getter + @Setter + public static class UpdateMemberDTO { + + @NotBlank(message = "비밀번호는 필수 입력값입니다.") + String password; + + String profileUrl; // 프로필 사진 URL + } } diff --git a/src/main/java/umc/codeplay/dto/MemberResponseDTO.java b/src/main/java/umc/codeplay/dto/MemberResponseDTO.java index dd2a4b2..265754f 100644 --- a/src/main/java/umc/codeplay/dto/MemberResponseDTO.java +++ b/src/main/java/umc/codeplay/dto/MemberResponseDTO.java @@ -24,4 +24,13 @@ public static class LoginResultDTO { String token; String refreshToken; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UpdateResultDTO { + String email; + String profileUrl; + } } diff --git a/src/main/java/umc/codeplay/jwt/JwtAuthenticationFilter.java b/src/main/java/umc/codeplay/jwt/JwtAuthenticationFilter.java index 9251fb2..fe2e1b7 100644 --- a/src/main/java/umc/codeplay/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/umc/codeplay/jwt/JwtAuthenticationFilter.java @@ -13,6 +13,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; +import umc.codeplay.config.security.CustomUserDetails; + public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; @@ -34,9 +36,10 @@ protected void doFilterInternal( // 2. 토큰 유효성 검사 if (jwtUtil.validateToken(token) && (jwtUtil.getTypeFromToken(token).equals("access"))) { - // 3. 토큰에서 사용자명 추출 + // 3. 토큰에서 사용자 정보 추출 String username = jwtUtil.getUsernameFromToken(token); - + System.out.println(username); + // String email = jwtUtil.getUsernameFromToken(token); List roles = jwtUtil.getRolesFromToken(token); List authorities = @@ -44,8 +47,12 @@ protected void doFilterInternal( .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); + // CustomUserDetails 객체 생성 후 저장 + CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities); + UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(username, null, authorities); + new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + System.out.println(authentication); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/src/main/java/umc/codeplay/service/EmailService.java b/src/main/java/umc/codeplay/service/EmailService.java new file mode 100644 index 0000000..971ebdb --- /dev/null +++ b/src/main/java/umc/codeplay/service/EmailService.java @@ -0,0 +1,88 @@ +package umc.codeplay.service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.code.status.ErrorStatus; +import umc.codeplay.apiPayLoad.exception.GeneralException; +import umc.codeplay.domain.Member; +import umc.codeplay.dto.EmailCodeDTO; +import umc.codeplay.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +public class EmailService { + + private final MemberRepository memberRepository; + // application.yml에 발신 메일 설정 필요 + private final JavaMailSender mailSender; + + // 인증번호 저장 + private final Map verificationCodes = + new ConcurrentHashMap<>(); + + // 비밀번호 찾기 인증번호 메일 보내기 + public void sendCode(String email) throws MessagingException { + Member member = + memberRepository + .findByEmail(email) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + // 인증번호 생성(5자리) + Random random = new Random(); + final String code = String.format("%05d", random.nextInt(100000)); + + // 인증번호 저장 및 만료시간 설정 + EmailCodeDTO.VerificationCode verificationCode = + new EmailCodeDTO.VerificationCode(code, LocalDateTime.now().plusMinutes(5)); + verificationCodes.put(email, verificationCode); + + try { + MimeMessage message = mailSender.createMimeMessage(); + String text = + "" + + "" + + "

안녕하세요, CodePlay입니다.

" + + "

비밀번호 재설정 코드입니다.

" + + "

인증번호: " + + code + + "

" + + "

이 코드는 5분간 유효합니다.

" + + "

CodePlay 팀 드림

" + + "" + + ""; + + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(email); // 수신자 이메일 + helper.setSubject("CodePlay 비밀번호 재설정 코드"); // 이메일 제목 + helper.setText(text, true); // HTML 본문을 true로 설정 + + mailSender.send(message); + } catch (MessagingException e) { + throw new GeneralException(ErrorStatus.EMAIL_SEND_ERROR); + } + } + + // 코드 검증 + public boolean verifyCode(String email, String code) { + EmailCodeDTO.VerificationCode verificationCode = verificationCodes.get(email); + if (verificationCode == null) { + return false; + } + if (verificationCode.getExpires().isBefore(LocalDateTime.now())) { + verificationCodes.remove(email); // 만료 코드 삭제 + return false; + } + return verificationCode.getCode().equals(code); + } +} diff --git a/src/main/java/umc/codeplay/service/LikeService.java b/src/main/java/umc/codeplay/service/LikeService.java index db9008d..7c13c76 100644 --- a/src/main/java/umc/codeplay/service/LikeService.java +++ b/src/main/java/umc/codeplay/service/LikeService.java @@ -1,6 +1,7 @@ package umc.codeplay.service; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -23,6 +24,7 @@ public class LikeService { private final MemberRepository memberRepository; private final MusicLikeRepository musicLikeRepository; + @Transactional public MusicLike addLike(String username, LikeRequestDTO.addLikeRequestDTO request) { Music music = @@ -39,6 +41,7 @@ public MusicLike addLike(String username, LikeRequestDTO.addLikeRequestDTO reque return musicLikeRepository.save(newLike); } + @Transactional public Music removeLike(String username, LikeRequestDTO.removeLikeRequestDTO request) { Music music = musicRepository diff --git a/src/main/java/umc/codeplay/service/MemberService.java b/src/main/java/umc/codeplay/service/MemberService.java index dda2090..61f0739 100644 --- a/src/main/java/umc/codeplay/service/MemberService.java +++ b/src/main/java/umc/codeplay/service/MemberService.java @@ -1,5 +1,7 @@ package umc.codeplay.service; +import jakarta.transaction.Transactional; + import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -60,4 +62,27 @@ public SocialStatus getSocialStatus(String email) { return member.getSocialStatus(); } } + + @Transactional + public Member updateMember(String email, MemberRequestDTO.UpdateMemberDTO requestDto) { + // MemberRepository의 findByEmail()을 사용하여 회원 조회 + Member member = + memberRepository + .findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("해당 이메일의 회원이 존재하지 않습니다.")); + + // 비밀번호 변경(입력값이 있을 경우만) + if (requestDto.getPassword() != null && !requestDto.getPassword().isEmpty()) { + String encodedPassword = passwordEncoder.encode(requestDto.getPassword()); + member.setPassword(encodedPassword); + } + + // 프로필 사진 변경(입력값이 있을 경우에만) + if (requestDto.getProfileUrl() != null && !requestDto.getProfileUrl().isEmpty()) { + member.setProfileUrl(requestDto.getProfileUrl()); + } + + memberRepository.save(member); + return member; + } } diff --git a/src/main/java/umc/codeplay/service/MusicService.java b/src/main/java/umc/codeplay/service/MusicService.java new file mode 100644 index 0000000..59101d5 --- /dev/null +++ b/src/main/java/umc/codeplay/service/MusicService.java @@ -0,0 +1,29 @@ +package umc.codeplay.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import umc.codeplay.apiPayLoad.code.status.ErrorStatus; +import umc.codeplay.apiPayLoad.exception.GeneralException; +import umc.codeplay.domain.Music; +import umc.codeplay.repository.MusicRepository; + +@Service +@RequiredArgsConstructor +public class MusicService { + + private final MusicRepository musicRepository; + + @Transactional + public void deleteMusic(Long id) { + + Music music = + musicRepository + .findById(id) + .orElseThrow(() -> new GeneralException(ErrorStatus.MUSIC_NOT_FOUND)); + + musicRepository.deleteById(id); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75a5102..8699fdf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,14 @@ spring: application: name: codeplay + mail: + host: smtp.gmail.com + port: 587 + username: dummy-email@gmail.com # 이메일 계정 + password: dummy-password + protocol: smt + tls: true + config: import: @@ -18,7 +26,7 @@ spring: jpa: hibernate: - ddl-auto: create # Hibernate 엔티티 스키마 자동 업데이트 + ddl-auto: update # Hibernate 엔티티 스키마 자동 업데이트 properties: jakarta.persistence.sharedCache.mode: ALL hibernate: @@ -62,4 +70,5 @@ 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" - additional-parameters: "" \ No newline at end of file + additional-parameters: "" +