diff --git a/build.gradle b/build.gradle index 8e3b645..b94a84b 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,8 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.12.2' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.2' + implementation 'org.springframework.boot:spring-boot-starter-mail' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/cmc/suppin/global/config/MailConfig.java b/src/main/java/com/cmc/suppin/global/config/MailConfig.java new file mode 100644 index 0000000..56ea75d --- /dev/null +++ b/src/main/java/com/cmc/suppin/global/config/MailConfig.java @@ -0,0 +1,88 @@ +package com.cmc.suppin.global.config; + +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +@RequiredArgsConstructor +public class MailConfig { + private final JavaMailSender javaMailSender; + + public boolean sendMail(String toEmail, String code) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(new InternetAddress("suppindev@gmail.com", "Suppin", "UTF-8")); + helper.setTo(toEmail); + helper.setSubject("Suppin 인증번호"); + + // Format the current date and time + String formattedDateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd, HH:mm")); + + // Use StringBuilder to construct the HTML email body + StringBuilder emailBody = new StringBuilder(); + emailBody.append("") + .append("") + .append("") + .append("") + .append("") + .append("Suppin Email Verification") + .append("") + .append("") + .append("
") + .append("
") + .append("\"Suppin") + .append("

[Suppin] 인증번호를 안내해 드립니다.

") + .append("
") + .append("
") + .append("

안녕하세요, Suppin을 이용해주셔서 감사합니다 :)

") + .append("

Suppin 회원가입을 위해 인증번호를 안내해 드립니다. 아래 인증번호를 입력하여 이메일 인증을 완료해 주세요.

") + .append("
") + .append(code) + .append("
") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("
인증 번호") + .append(code) + .append("
요청 일시") + .append(formattedDateTime) + .append("
") + .append("
") + .append("
") + .append("

감사합니다.

") + .append("

※ 본 메일은 Suppin 서비스 이용에 관한 안내 메일입니다.

") + .append("
") + .append("
") + .append("") + .append(""); + + helper.setText(emailBody.toString(), true); + + // Add inline image + ClassPathResource logoImage = new ClassPathResource("static/images/suppin-logo.png"); + helper.addInline("suppinLogo", logoImage); + + javaMailSender.send(message); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + return true; + } +} + + + + diff --git a/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java b/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java index 6dc4bb6..a525134 100644 --- a/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java +++ b/src/main/java/com/cmc/suppin/global/exception/MemberErrorCode.java @@ -9,10 +9,12 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND("mem-404/01", HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), VALIDATION_FAILED("mem-400/01", HttpStatus.BAD_REQUEST, "입력값에 대한 검증에 실패했습니다."), MEMBER_ALREADY_DELETED("mem-400/02", HttpStatus.BAD_REQUEST, "탈퇴한 회원입니다."), - PASSWORD_CONFIRM_NOT_MATCHED("mem-400/03", HttpStatus.BAD_REQUEST, "비밀번호가 확인이 일치하지 않습니다."), + PASSWORD_CONFIRM_NOT_MATCHED("mem-400/03", HttpStatus.BAD_REQUEST, "비밀번호가 잘못 입력되었습니다."), + PASSWORD_FORMAT_NOT_MATCHED("mem-400/04", HttpStatus.BAD_REQUEST, "비밀번호는 8자 이상 20자 이하로 입력해주세요."), DUPLICATE_MEMBER_EMAIL("mem-409/01", HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), DUPLICATE_NICKNAME("mem-409/01", HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."); + private final String code; private final HttpStatus status; private final String message; diff --git a/src/main/java/com/cmc/suppin/global/response/ApiResponse.java b/src/main/java/com/cmc/suppin/global/response/ApiResponse.java index 7540613..4194662 100644 --- a/src/main/java/com/cmc/suppin/global/response/ApiResponse.java +++ b/src/main/java/com/cmc/suppin/global/response/ApiResponse.java @@ -30,4 +30,16 @@ public static ApiResponse of(ResponseCode responseCode, T data) { public static ApiResponse of(ResponseCode responseCode) { return new ApiResponse<>(responseCode.getCode(), responseCode.getMessage(), null); } + + public static ApiResponse confirm(T data) { + return new ApiResponse<>(ResponseCode.CONFIRM.getCode(), ResponseCode.CONFIRM.getMessage(), data); + } + + public static ApiResponse confirm(ResponseCode responseCode, T data) { + return new ApiResponse<>(responseCode.getCode(), responseCode.getMessage(), data); + } + + public static ApiResponse confirm(ResponseCode responseCode) { + return new ApiResponse<>(responseCode.getCode(), responseCode.getMessage(), null); + } } diff --git a/src/main/java/com/cmc/suppin/global/response/ResponseCode.java b/src/main/java/com/cmc/suppin/global/response/ResponseCode.java index 092e3c7..b8cc642 100644 --- a/src/main/java/com/cmc/suppin/global/response/ResponseCode.java +++ b/src/main/java/com/cmc/suppin/global/response/ResponseCode.java @@ -6,7 +6,8 @@ public enum ResponseCode { SUCCESS("200", "정상 처리되었습니다."), - CREATE("201", "정상적으로 생성되었습니다."); + CREATE("201", "정상적으로 생성되었습니다."), + CONFIRM("202", "검증 및 확인 되었습니다."); private final String code; private final String message; diff --git a/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java b/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java index bb0f6a9..fcee56a 100644 --- a/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java +++ b/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java @@ -102,14 +102,16 @@ public SecurityFilterChain securityFilterChainDefault(HttpSecurity http) throws return http.build(); } + // 인증 및 인가가 필요한 엔드포인트에 적용되는 RequestMatcher private RequestMatcher[] requestHasRoleUser() { List requestMatchers = List.of( antMatcher("/api/v1/members/**"), - antMatcher(PATCH, "/api/members") + antMatcher(PATCH, "/api/v1/members") ); return requestMatchers.toArray(RequestMatcher[]::new); } + // permitAll 권한을 가진 엔드포인트에 적용되는 RequestMatcher private RequestMatcher[] requestPermitAll() { List requestMatchers = List.of( antMatcher("/"), @@ -117,8 +119,10 @@ private RequestMatcher[] requestPermitAll() { antMatcher("/actuator/**"), antMatcher("/v3/api-docs/**"), antMatcher("/api/v1/members/login/**"), - antMatcher("/api/v1/members/join"), - antMatcher("/api/v1/survey/reply/**") + antMatcher("/api/v1/members/join/**"), + antMatcher("/api/v1/members/checkUserId"), + antMatcher("/api/v1/members/checkEmail"), + antMatcher("/api/v1/survey/reply/**") // 설문조사 응답 시 적용 ); return requestMatchers.toArray(RequestMatcher[]::new); } diff --git a/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java index b3c703d..7adb40f 100644 --- a/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/cmc/suppin/global/security/jwt/JwtTokenProvider.java @@ -3,6 +3,7 @@ import com.cmc.suppin.global.security.user.UserDetailsImpl; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; @@ -17,10 +18,7 @@ import javax.crypto.SecretKey; import java.time.Instant; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; import static org.springframework.util.StringUtils.hasText; @@ -33,17 +31,15 @@ public class JwtTokenProvider { private static final String AUTHENTICATION_CLAIM_NAME = "roles"; private static final String AUTHENTICATION_SCHEME = "Bearer "; + // 토큰 블랙리스트 관리용 Set + private final Set tokenBlacklist = new HashSet<>(); + @Value("${JWT_SECRET_KEY}") private String secretKey; @Value("${ACCESS_EXPIRY_SECONDS}") private int accessExpirySeconds; -// @Value("${jwt.refresh-expiry-seconds}") -// private int refreshExpirySeconds; - -// private final RedisKeyRepository redisKeyRepository; - public String createAccessToken(UserDetailsImpl userDetails) { Instant now = Instant.now(); Instant expirationTime = now.plusSeconds(accessExpirySeconds); @@ -64,17 +60,6 @@ public String createAccessToken(UserDetailsImpl userDetails) { .compact(); } -// public String createRefreshToken() { -// Instant now = Instant.now(); -// Instant expirationTime = now.plusSeconds(refreshExpirySeconds); -// -// return Jwts.builder() -// .issuedAt(Date.from(now)) -// .expiration(Date.from(expirationTime)) -// .signWith(extractSecretKey()) -// .compact(); -// } - /** * 권한 체크 */ @@ -121,12 +106,31 @@ private Claims verifyAndExtractClaims(String accessToken) { .getPayload(); } + /* public void validateAccessToken(String accessToken) { Jwts.parser() .verifyWith(extractSecretKey()) .build() .parse(accessToken); } + */ + + public boolean validateAccessToken(String accessToken) { + if (isTokenBlacklisted(accessToken)) { + return false; + } + + try { + Jwts.parser() + .verifyWith(extractSecretKey()) + .build() + .parse(accessToken); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + return false; + } + } /** * SecretKey 추출 @@ -134,6 +138,17 @@ public void validateAccessToken(String accessToken) { private SecretKey extractSecretKey() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); } + + /** + * 토큰 블랙리스트 관리 + */ + public void addTokenToBlacklist(String token) { + tokenBlacklist.add(token); + } + + public boolean isTokenBlacklisted(String token) { + return tokenBlacklist.contains(token); + } } diff --git a/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java b/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java index 378d38b..61d2708 100644 --- a/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java +++ b/src/main/java/com/cmc/suppin/global/security/service/MemberDetailsService.java @@ -1,5 +1,6 @@ package com.cmc.suppin.global.security.service; +import com.cmc.suppin.global.enums.UserStatus; import com.cmc.suppin.global.security.exception.SecurityException; import com.cmc.suppin.global.security.user.UserDetailsImpl; import com.cmc.suppin.member.domain.Member; @@ -26,7 +27,7 @@ public class MemberDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { - Member member = memberRepository.findByUserId(userId) + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) .orElseThrow(() -> new SecurityException(MEMBER_NOT_FOUND)); if (member.isDeleted()) { diff --git a/src/main/java/com/cmc/suppin/member/controller/MemberApi.java b/src/main/java/com/cmc/suppin/member/controller/MemberApi.java index 7fe644b..b9f4212 100644 --- a/src/main/java/com/cmc/suppin/member/controller/MemberApi.java +++ b/src/main/java/com/cmc/suppin/member/controller/MemberApi.java @@ -8,7 +8,7 @@ import com.cmc.suppin.member.controller.dto.MemberResponseDTO; import com.cmc.suppin.member.converter.MemberConverter; import com.cmc.suppin.member.domain.Member; -import com.cmc.suppin.member.service.command.MemberService; +import com.cmc.suppin.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -37,20 +37,45 @@ public ResponseEntity> join(@Reques return ResponseEntity.ok(ApiResponse.of(MemberConverter.toJoinResultDTO(member))); } + // 이메일 인증번호 요청(회원가입 시) + @PostMapping("/join/email/auth") + @Operation(summary = "이메일 인증번호 요청(회원가입 시) API", description = "request : email(이메일을 입력하면 해당 이메일로 인증번호 전송), response: 인증번호 전송 성공 시 true, 실패 시 false") + public ResponseEntity> requestEmailAuth(@RequestBody @Valid MemberRequestDTO.EmailRequestDTO request) { + memberService.requestEmailVerification(request.getEmail()); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); + } + + // 이메일 인증번호 확인(회원가입 시) + @PostMapping("/join/email/verification") + @Operation(summary = "이메일 인증번호 확인 API", description = "request : email, verificationCode, response: 인증번호 일치 시 true, 불일치 시 false") + public ResponseEntity> verifyEmailCode(@RequestBody @Valid MemberRequestDTO.EmailVerificationDTO request) { + memberService.verifyEmailCode(request.getEmail(), request.getVerificationCode()); + return ResponseEntity.ok(ApiResponse.confirm(ResponseCode.CONFIRM)); + } + // 아이디 중복 체크 - @PostMapping("/checkUserId") + @GetMapping("/checkUserId") @Operation(summary = "아이디 중복 체크 API", description = "request : userId, response: 중복이면 false, 중복 아니면 true") - public ResponseEntity> checkUserId(@RequestBody MemberRequestDTO.IdConfirmDTO request) { - boolean checkUserId = memberService.confirmUserId(request); + public ResponseEntity> checkUserId(@RequestParam String userId) { + boolean checkUserId = memberService.confirmUserId(userId); - return ResponseEntity.ok(ApiResponse.of(MemberConverter.toIdConfirmResultDTO(checkUserId))); + return ResponseEntity.ok(ApiResponse.confirm(MemberConverter.toIdConfirmResultDTO(checkUserId))); } + // 이메일 중복 체크 + @GetMapping("/checkEmail") + @Operation(summary = "이메일 중복 체크 API", description = "request : email, response: 중복이면 false, 중복 아니면 true") + public ResponseEntity> checkEmail(@RequestParam String email) { + boolean checkEmail = memberService.confirmEmail(email); + + return ResponseEntity.ok(ApiResponse.confirm(MemberConverter.toEmailConfirmResultDTO(checkEmail))); + } + + // 회원탈퇴 @DeleteMapping("/delete") - @Operation(summary = "회원탈퇴 API", description = "로그인 시 발급받은 토큰으로 인가 필요") - public ResponseEntity> deleteMember( - @CurrentAccount Account account) { + @Operation(summary = "회원탈퇴 API", description = "로그인 시 발급받은 토큰으로 인가 필요, Authentication 헤더에 토큰을 넣어서 요청") + public ResponseEntity> deleteMember(@CurrentAccount Account account) { memberService.deleteMember(account.id()); return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); } @@ -64,53 +89,38 @@ public ResponseEntity> login(@Re return ResponseEntity.ok(ApiResponse.of(response)); } - /** - * TODO: 로그아웃, 비밀번호 변경, 회원정보 상세 조회, 회원정보 수정 API - */ - -// // 로그아웃 -// @PostMapping("/logout") -// @Operation(summary = "로그아웃 API", description = "JWT 토큰을 헤더에 포함시켜 보내주시면 됩니다.") -// public ApiResponse logout(@AuthenticationPrincipal MemberDetails memberDetails) { -// if (memberDetails == null) { -// return ApiResponse.onFailure("403", "인증된 사용자만 로그아웃할 수 있습니다.", null); -// } -// memberCommandService.logout(memberDetails.getUserId()); -// return ApiResponse.onSuccess(null, SuccessStatus.MEMBER_LOGOUT_SUCCESS); -// } -// -// // 비밀번호 변경 -// @PutMapping("/changePassword") -// @Operation(summary = "비밀번호 변경 API", description = "request : userId, password, newPassword") -// public ApiResponse changePassword(@AuthenticationPrincipal MemberDetails memberDetails, @RequestBody @Valid MemberRequestDTO.ChangePasswordDTO request) { -// if (memberDetails == null) { -// return ApiResponse.onFailure("403", "인증된 사용자만 비밀번호를 변경할 수 있습니다.", null); -// } -// memberCommandService.changePassword(memberDetails.getUserId(), request); -// return ApiResponse.onSuccess(null, SuccessStatus.MEMBER_CHANGE_PASSWORD_SUCCESS); -// } -// -// // 회원정보 상세 조회(마이페이지) -// @GetMapping("/info") -// @Operation(summary = "회원정보 상세 조회 API", description = "JWT 토큰을 헤더에 포함시켜 보내주시면 됩니다.") -// public ApiResponse getMemberInfo(@AuthenticationPrincipal MemberDetails memberDetails) { -// if (memberDetails == null) { -// return ApiResponse.onFailure("403", "인증된 사용자만 조회할 수 있습니다.", null); -// } -// Member member = memberCommandService.getMemberInfo(memberDetails.getUserId()); -// return ApiResponse.onSuccess(MemberConverter.toMemberInfoDTO(member), SuccessStatus.MEMBER_INFO_SUCCESS); -// } -// -// // 회원정보 수정 -// @PutMapping("/info/update") -// @Operation(summary = "회원정보 수정 API", description = "request : userId, name, phone, email") -// public ApiResponse updateMemberInfo(@AuthenticationPrincipal MemberDetails memberDetails, @RequestBody @Valid MemberRequestDTO.UpdateMemberInfoDTO request) { -// if (memberDetails == null) { -// return ApiResponse.onFailure("403", "인증된 사용자만 수정할 수 있습니다.", null); -// } -// memberCommandService.updateMemberInfo(memberDetails.getUserId(), request); -// return ApiResponse.onSuccess(null, SuccessStatus.MEMBER_UPDATE_SUCCESS); -// } + // 로그아웃 + @PostMapping("/logout") + @Operation(summary = "로그아웃 API", description = "로그인 시 발급받은 토큰으로 인가 필요, Authentication 헤더에 토큰을 넣어서 요청") + public ResponseEntity> logout(@CurrentAccount Account account) { + memberService.logout(account.id()); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); + } + + // 비밀번호 변경 + @PostMapping("/password/update") + @Operation(summary = "비밀번호 변경 API", description = "request : password, newPassword") + public ResponseEntity> updatePassword(@RequestBody @Valid MemberRequestDTO.PasswordUpdateDTO request, @CurrentAccount Account account) { + memberService.updatePassword(request, account.id()); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); + } + + // 현재 비밀번호 확인 + @GetMapping("/password/check") + @Operation(summary = "현재 비밀번호 확인 API", description = "request : password") + public ResponseEntity> checkPassword(@RequestParam String password, @CurrentAccount Account account) { + memberService.checkPassword(password, account.id()); + return ResponseEntity.ok(ApiResponse.confirm(ResponseCode.CONFIRM)); + } + + // 회원정보 상세 조회 + @GetMapping("/me") + @Operation(summary = "회원정보 상세 조회 API", description = "로그인 시 발급받은 토큰으로 인가 필요, Authentication 헤더에 토큰을 넣어서 요청") + public ResponseEntity> getUserDetail(@CurrentAccount Account account) { + MemberResponseDTO.MemberDetailsDTO memberDetails = memberService.getMemberDetails(account.id()); + return ResponseEntity.ok(ApiResponse.of(memberDetails)); + } + // TODO: 아이디 찾기, 비밀번호 찾기 API 구현 필요 diff --git a/src/main/java/com/cmc/suppin/member/controller/dto/MemberRequestDTO.java b/src/main/java/com/cmc/suppin/member/controller/dto/MemberRequestDTO.java index 91b4604..871ddd2 100644 --- a/src/main/java/com/cmc/suppin/member/controller/dto/MemberRequestDTO.java +++ b/src/main/java/com/cmc/suppin/member/controller/dto/MemberRequestDTO.java @@ -31,6 +31,9 @@ public static class JoinDTO { @NotBlank(message = "휴대폰 번호를 입력해주세요") private String phone; + + @NotBlank(message = "이메일 인증번호를 입력해주세요") + private String verificationCode; } @Getter @@ -40,6 +43,13 @@ public static class IdConfirmDTO { private String userId; } + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class EmailConfirmDTO { + private String email; + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -47,4 +57,33 @@ public static class LoginRequestDTO { private String userId; private String password; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class PasswordUpdateDTO { + private String password; + private String newPassword; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class EmailRequestDTO { + @NotBlank(message = "이메일을 입력해주세요") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class EmailVerificationDTO { + @NotBlank(message = "이메일을 입력해주세요") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; + + @NotBlank(message = "인증번호를 입력해주세요") + private String verificationCode; + } } diff --git a/src/main/java/com/cmc/suppin/member/controller/dto/MemberResponseDTO.java b/src/main/java/com/cmc/suppin/member/controller/dto/MemberResponseDTO.java index 86bbad1..ac427eb 100644 --- a/src/main/java/com/cmc/suppin/member/controller/dto/MemberResponseDTO.java +++ b/src/main/java/com/cmc/suppin/member/controller/dto/MemberResponseDTO.java @@ -18,6 +18,7 @@ public static class JoinResultDTO { String userId; String name; String email; + String phoneNumber; LocalDateTime createdAt; } @@ -27,6 +28,15 @@ public static class JoinResultDTO { @AllArgsConstructor public static class IdConfirmResultDTO { Boolean checkUserId; + + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class EmailConfirmResultDTO { + Boolean checkEmail; } @Getter @@ -36,5 +46,26 @@ public static class IdConfirmResultDTO { public static class LoginResponseDTO { private String token; private String userId; + + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CheckPasswordDTO { + private Boolean checkPassword; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MemberDetailsDTO { + private String userId; + private String name; + private String email; + private String phoneNumber; + private LocalDateTime createdAt; } } diff --git a/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java b/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java index 14e6820..21727da 100644 --- a/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java +++ b/src/main/java/com/cmc/suppin/member/converter/MemberConverter.java @@ -37,10 +37,26 @@ public static MemberResponseDTO.IdConfirmResultDTO toIdConfirmResultDTO(boolean .build(); } + public static MemberResponseDTO.EmailConfirmResultDTO toEmailConfirmResultDTO(boolean checkEmail) { + return MemberResponseDTO.EmailConfirmResultDTO.builder() + .checkEmail(checkEmail) + .build(); + } + public static MemberResponseDTO.LoginResponseDTO toLoginResponseDTO(String token, Member member) { return MemberResponseDTO.LoginResponseDTO.builder() .token(token) .userId(member.getUserId()) .build(); } + + public static MemberResponseDTO.MemberDetailsDTO toMemberDetailsDTO(Member member) { + return MemberResponseDTO.MemberDetailsDTO.builder() + .userId(member.getUserId()) + .name(member.getName()) + .email(member.getEmail()) + .phoneNumber(member.getPhoneNumber()) + .createdAt(member.getCreatedAt()) + .build(); + } } diff --git a/src/main/java/com/cmc/suppin/member/domain/EmailVerificationToken.java b/src/main/java/com/cmc/suppin/member/domain/EmailVerificationToken.java new file mode 100644 index 0000000..ee52117 --- /dev/null +++ b/src/main/java/com/cmc/suppin/member/domain/EmailVerificationToken.java @@ -0,0 +1,35 @@ +package com.cmc.suppin.member.domain; + +import com.cmc.suppin.global.domain.BaseDateTimeEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "email_verification_tokens") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EmailVerificationToken extends BaseDateTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Email + private String email; + + private String token; + + private LocalDateTime expiryDate; + + public boolean isExpired() { + return expiryDate.isBefore(LocalDateTime.now()); + } +} diff --git a/src/main/java/com/cmc/suppin/member/domain/Member.java b/src/main/java/com/cmc/suppin/member/domain/Member.java index 7343681..0f7a5d3 100644 --- a/src/main/java/com/cmc/suppin/member/domain/Member.java +++ b/src/main/java/com/cmc/suppin/member/domain/Member.java @@ -65,5 +65,9 @@ public boolean isDeleted() { public void delete() { this.status = UserStatus.DELETED; } + + public void updatePassword(String encode) { + this.password = encode; + } } diff --git a/src/main/java/com/cmc/suppin/member/domain/repository/EmailVerificationTokenRepository.java b/src/main/java/com/cmc/suppin/member/domain/repository/EmailVerificationTokenRepository.java new file mode 100644 index 0000000..dab3334 --- /dev/null +++ b/src/main/java/com/cmc/suppin/member/domain/repository/EmailVerificationTokenRepository.java @@ -0,0 +1,12 @@ +package com.cmc.suppin.member.domain.repository; + +import com.cmc.suppin.member.domain.EmailVerificationToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EmailVerificationTokenRepository extends JpaRepository { + Optional findByEmailAndToken(String email, String token); + + void deleteByEmail(String email); +} diff --git a/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java b/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java index 985a7cd..8545dfd 100644 --- a/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java +++ b/src/main/java/com/cmc/suppin/member/domain/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.cmc.suppin.member.domain.repository; +import com.cmc.suppin.global.enums.UserStatus; import com.cmc.suppin.member.domain.Member; import org.springframework.data.jpa.repository.JpaRepository; @@ -7,11 +8,13 @@ public interface MemberRepository extends JpaRepository { - Boolean existsByUserId(String userId); + Boolean existsByUserIdAndStatusNot(String userId, UserStatus status); - Boolean existsByEmail(String email); + Boolean existsByEmailAndStatusNot(String email, UserStatus status); - Optional findByUserId(String userId); + Optional findByUserIdAndStatusNot(String userId, UserStatus status); + + Optional findByIdAndStatusNot(Long id, UserStatus status); void deleteByUserId(String userId); } diff --git a/src/main/java/com/cmc/suppin/member/service/MemberService.java b/src/main/java/com/cmc/suppin/member/service/MemberService.java new file mode 100644 index 0000000..44c0629 --- /dev/null +++ b/src/main/java/com/cmc/suppin/member/service/MemberService.java @@ -0,0 +1,224 @@ +package com.cmc.suppin.member.service; + +import com.cmc.suppin.global.config.MailConfig; +import com.cmc.suppin.global.enums.UserStatus; +import com.cmc.suppin.global.exception.MemberErrorCode; +import com.cmc.suppin.global.security.jwt.JwtTokenProvider; +import com.cmc.suppin.global.security.user.UserDetailsImpl; +import com.cmc.suppin.member.controller.dto.MemberRequestDTO; +import com.cmc.suppin.member.controller.dto.MemberResponseDTO; +import com.cmc.suppin.member.converter.MemberConverter; +import com.cmc.suppin.member.domain.EmailVerificationToken; +import com.cmc.suppin.member.domain.Member; +import com.cmc.suppin.member.domain.repository.EmailVerificationTokenRepository; +import com.cmc.suppin.member.domain.repository.MemberRepository; +import com.cmc.suppin.member.exception.MemberException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final MemberConverter memberConverter; + private final JwtTokenProvider jwtTokenProvider; + private final AuthenticationManager authenticationManager; + private final PasswordEncoder passwordEncoder; + private final EmailVerificationTokenRepository emailVerificationTokenRepository; + private final MailConfig mailConfig; + + public boolean requestEmailVerification(String email) { + String code = generateVerificationCode(); + saveVerificationToken(email, code); + return mailConfig.sendMail(email, code); + } + + public boolean verifyEmailCode(String email, String code) { + return verifyToken(email, code); + } + + /** + * 회원가입 + */ + public Member join(MemberRequestDTO.JoinDTO request) { + // 이메일 인증 체크 + if (!verifyEmailCode(request.getEmail(), request.getVerificationCode())) { + throw new IllegalArgumentException("이메일 인증이 필요합니다."); + } + + // 중복된 아이디 체크 + if (memberRepository.existsByUserIdAndStatusNot(request.getUserId(), UserStatus.DELETED)) { + throw new IllegalArgumentException("이미 존재하는 유저입니다."); + } + + // 비밀번호 조건 검증 + String password = request.getPassword(); + if (!isValidPassword(password)) { + throw new IllegalArgumentException("비밀번호는 8~20자 영문, 숫자, 특수문자를 사용해야 합니다."); + } + + // DTO를 Entity로 변환 + Member member = memberConverter.toEntity(request, passwordEncoder); + + // 회원 정보 저장 + memberRepository.save(member); + + return member; + } + + /** + * ID 중복 확인 + */ + public Boolean confirmUserId(String userId) { + // 아이디 중복 체크 + return !memberRepository.existsByUserIdAndStatusNot(userId, UserStatus.DELETED); + } + + /** + * 이메일 중복 확인 + */ + public Boolean confirmEmail(String email) { + // 이메일 중복 체크 + return !memberRepository.existsByEmailAndStatusNot(email, UserStatus.DELETED); + } + + /** + * 회원 탈퇴 + */ + public void deleteMember(Long memberId) { + final Member member = getMember(memberId); + if (member.isDeleted()) { + throw new MemberException(MemberErrorCode.MEMBER_NOT_FOUND); + } + member.delete(); + } + + /** + * 로그인 + */ + public MemberResponseDTO.LoginResponseDTO login(MemberRequestDTO.LoginRequestDTO request) { + validateMember(request.getUserId()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + request.getUserId(), + request.getPassword() + ); + + Authentication authenticate = authenticationManager.authenticate(authentication); + + String accessToken = jwtTokenProvider.createAccessToken((UserDetailsImpl) authenticate.getPrincipal()); + + return MemberResponseDTO.LoginResponseDTO.builder() + .token(accessToken) + .userId(request.getUserId()) + .build(); + } + + /** + * 로그아웃 + */ + public void logout(Long accountId) { + // 현재 인증된 사용자의 토큰을 무효화하는 로직 + String accessToken = SecurityContextHolder.getContext().getAuthentication().getCredentials().toString(); + if (jwtTokenProvider.validateAccessToken(accessToken)) { + jwtTokenProvider.addTokenToBlacklist(accessToken); // 토큰 블랙리스트에 추가 + } + } + + private Member getMember(Long memberId) { + return memberRepository.findByIdAndStatusNot(memberId, UserStatus.DELETED) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + public MemberResponseDTO.MemberDetailsDTO getMemberDetails(Long id) { + Member member = memberRepository.findByIdAndStatusNot(id, UserStatus.DELETED) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return MemberConverter.toMemberDetailsDTO(member); + } + + public void updatePassword(MemberRequestDTO.PasswordUpdateDTO request, Long id) { + validatePasswordFormat(request.getNewPassword()); + Member member = getMember(id); + + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new MemberException(MemberErrorCode.PASSWORD_CONFIRM_NOT_MATCHED); + } + + member.updatePassword(passwordEncoder.encode(request.getNewPassword())); + } + + private String generateVerificationCode() { + SecureRandom random = new SecureRandom(); + int code = random.nextInt(900000) + 100000; // 6자리 숫자 생성 (100000 ~ 999999) + return String.valueOf(code); + } + + private void saveVerificationToken(String email, String code) { + EmailVerificationToken verificationToken = EmailVerificationToken.builder() + .email(email) + .token(code) + .expiryDate(LocalDateTime.now().plusHours(1)) + .build(); + + emailVerificationTokenRepository.deleteByEmail(email); + emailVerificationTokenRepository.save(verificationToken); + } + + private boolean verifyToken(String email, String token) { + Optional verificationTokenOpt = emailVerificationTokenRepository.findByEmailAndToken(email, token); + if (verificationTokenOpt.isPresent()) { + EmailVerificationToken verificationToken = verificationTokenOpt.get(); + return !verificationToken.isExpired(); + } + return false; + } + + /** + * 검증 메서드 + */ + private boolean isValidPassword(String password) { + return password.matches("(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}"); + } + + private void validateMember(String userId) { + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (member.isDeleted()) { + throw new MemberException(MemberErrorCode.MEMBER_NOT_FOUND); + } + } + + private void validateDuplicateEmail(MemberRequestDTO.JoinDTO request) { + if (Boolean.TRUE.equals(memberRepository.existsByEmailAndStatusNot(request.getEmail(), UserStatus.DELETED))) { + throw new MemberException(MemberErrorCode.DUPLICATE_MEMBER_EMAIL); + } + } + + public void validatePasswordFormat(String password) { + if (!password.matches("(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}")) { + throw new MemberException(MemberErrorCode.PASSWORD_FORMAT_NOT_MATCHED); + } + } + + public void checkPassword(String password, Long id) { + Member member = getMember(id); + if (!passwordEncoder.matches(password, member.getPassword())) { + throw new MemberException(MemberErrorCode.PASSWORD_CONFIRM_NOT_MATCHED); + } + } +} diff --git a/src/main/java/com/cmc/suppin/member/service/command/MemberService.java b/src/main/java/com/cmc/suppin/member/service/command/MemberService.java deleted file mode 100644 index 6740516..0000000 --- a/src/main/java/com/cmc/suppin/member/service/command/MemberService.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.cmc.suppin.member.service.command; - -import com.cmc.suppin.global.exception.MemberErrorCode; -import com.cmc.suppin.global.security.jwt.JwtTokenProvider; -import com.cmc.suppin.global.security.user.UserDetailsImpl; -import com.cmc.suppin.member.controller.dto.MemberRequestDTO; -import com.cmc.suppin.member.controller.dto.MemberResponseDTO; -import com.cmc.suppin.member.converter.MemberConverter; -import com.cmc.suppin.member.domain.Member; -import com.cmc.suppin.member.domain.repository.MemberRepository; -import com.cmc.suppin.member.exception.MemberException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Slf4j -@RequiredArgsConstructor -@Transactional -public class MemberService { - - private final MemberRepository memberRepository; - private final MemberConverter memberConverter; - private final JwtTokenProvider jwtTokenProvider; - private final AuthenticationManager authenticationManager; - private final PasswordEncoder passwordEncoder; - - /** - * 회원가입 - */ - public Member join(MemberRequestDTO.JoinDTO request) { - // 중복된 아이디 체크 - if (memberRepository.existsByUserId(request.getUserId())) { - throw new IllegalArgumentException("이미 존재하는 유저입니다."); - } - - // 비밀번호 조건 검증 - String password = request.getPassword(); - if (!isValidPassword(password)) { - throw new IllegalArgumentException("비밀번호는 8~20자 영문, 숫자, 특수문자를 사용해야 합니다."); - } - - // DTO를 Entity로 변환 - Member member = memberConverter.toEntity(request, passwordEncoder); - - // 회원 정보 저장 - memberRepository.save(member); - - return member; - } - - /** - * ID 중복 확인 - */ - public Boolean confirmUserId(MemberRequestDTO.IdConfirmDTO request) { - // 아이디 중복 체크 - return !memberRepository.existsByUserId(request.getUserId()); - } - - /** - * 이메일 중복 확인 - */ - public Boolean confirmEmail(MemberRequestDTO.JoinDTO request) { - // 이메일 중복 체크 - validateDuplicateEmail(request); - return true; - } - - /** - * 회원 탈퇴 - */ - public void deleteMember(Long memberId) { - final Member member = getMember(memberId); - if (member.isDeleted()) { - throw new MemberException(MemberErrorCode.MEMBER_NOT_FOUND); - } - member.delete(); - } - - /** - * 로그인 - */ - public MemberResponseDTO.LoginResponseDTO login(MemberRequestDTO.LoginRequestDTO request) { - validateMember(request.getUserId()); - - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - request.getUserId(), - request.getPassword() - ); - - Authentication authenticate = authenticationManager.authenticate(authentication); - - String accessToken = jwtTokenProvider.createAccessToken((UserDetailsImpl) authenticate.getPrincipal()); - - return MemberResponseDTO.LoginResponseDTO.builder() - .token(accessToken) - .userId(request.getUserId()) - .build(); - } - - // 검증 메서드 - private boolean isValidPassword(String password) { - return password.matches("(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}"); - } - - private void validateMember(String userId) { - Member member = memberRepository.findByUserId(userId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - if (member.isDeleted()) { - throw new MemberException(MemberErrorCode.MEMBER_NOT_FOUND); - } - } - - private Member getMember(Long memberId) { - return memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - } - - private void validateDuplicateEmail(MemberRequestDTO.JoinDTO request) { - if (Boolean.TRUE.equals(memberRepository.existsByEmail(request.getEmail()))) { - throw new MemberException(MemberErrorCode.DUPLICATE_MEMBER_EMAIL); - } - } - - -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 881f5bf..1a2c453 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,24 @@ spring: show_sql: true format_sql: true + mail: + host: smtp.gmail.com + port: 587 + username: ${SPRING_MAIL_USERNAME} + password: ${SPRING_MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + ssl: + trust: smtp.gmail.com + protocol: smtp + default-encoding: UTF-8 + test-connection: false + jwt: token: secret: ${JWT_TOKEN_SECRET} diff --git a/src/main/resources/static/images/suppin-logo.png b/src/main/resources/static/images/suppin-logo.png new file mode 100644 index 0000000..3a48299 Binary files /dev/null and b/src/main/resources/static/images/suppin-logo.png differ