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("

")
+ .append("
[Suppin] 인증번호를 안내해 드립니다.
")
+ .append("
")
+ .append("
")
+ .append("
안녕하세요, Suppin을 이용해주셔서 감사합니다 :)
")
+ .append("
Suppin 회원가입을 위해 인증번호를 안내해 드립니다. 아래 인증번호를 입력하여 이메일 인증을 완료해 주세요.
")
+ .append("
")
+ .append(code)
+ .append("
")
+ .append("
")
+ .append("")
+ .append("인증 번호 | ")
+ .append(code)
+ .append(" |
")
+ .append("요청 일시 | ")
+ .append(formattedDateTime)
+ .append(" |
")
+ .append("")
+ .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