Skip to content

Commit 80f201b

Browse files
authored
Merge pull request #87 from asowjdan/feat/41-member
Feat[member]: 비밀번호 재설정 기능 추가
2 parents cd0ea8c + 7ba260a commit 80f201b

File tree

20 files changed

+1037
-15
lines changed

20 files changed

+1037
-15
lines changed

.github/workflows/CI-CD_Pipeline.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ jobs:
7575
7676
# JPA 설정 (application-test.yml에서 참조)
7777
TEST_JPA_HIBERNATE_DDL_AUTO=create-drop
78+
79+
email_address=${{ secrets.EMAIL_ADDRESS }}
80+
send_email_password=${{ secrets.EMAIL_PASSWORD }}
81+
send_email_address=${{ secrets.SEND_EMAIL_ADDRESS }}
7882
7983
# Redis 설정 (application-test.yml에서 참조, GitHub Actions 서비스 사용)
8084
TEST_REDIS_HOST=localhost
@@ -204,7 +208,7 @@ jobs:
204208
PROD_DATASOURCE_URL=jdbc:mysql://mysql_1:3306/${{ secrets.DB_NAME }}
205209
PROD_DATASOURCE_USERNAME=${{ secrets.DB_USER }}
206210
PROD_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }}
207-
211+
208212
PROD_REDIS_HOST=redis_1
209213
PROD_REDIS_PORT=6379
210214
PROD_REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
@@ -232,15 +236,15 @@ jobs:
232236
PROD_DATASOURCE_DRIVER=com.mysql.cj.jdbc.Driver
233237
PROD_DATASOURCE_USERNAME=root
234238
PROD_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }}
235-
239+
236240
PROD_REDIS_HOST=redis_1
237241
PROD_REDIS_PORT=6379
238242
PROD_REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
239243
EOF
240244
241245
# EC2에서 GHCR 로그인
242246
echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
243-
247+
244248
# 최신 이미지 pull & 컨테이너 실행
245249
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ env.DOCKER_IMAGE_NAME }}:latest
246250
docker stop app1 2>/dev/null || true

backend/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies {
3535
implementation 'org.springframework.boot:spring-boot-starter-validation'
3636
implementation 'org.springframework.boot:spring-boot-starter-web'
3737
implementation 'org.springframework.boot:spring-boot-starter-actuator'
38+
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5'
3839

3940
// API Documentation (문서화)
4041
implementation 'org.apache.commons:commons-lang3:3.18.0'

backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java

Lines changed: 165 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package com.ai.lawyer.domain.member.controller;
22

3-
import com.ai.lawyer.domain.member.dto.MemberLoginRequest;
4-
import com.ai.lawyer.domain.member.dto.MemberResponse;
5-
import com.ai.lawyer.domain.member.dto.MemberSignupRequest;
3+
import com.ai.lawyer.domain.member.dto.*;
64
import com.ai.lawyer.domain.member.service.MemberService;
75
import io.swagger.v3.oas.annotations.Operation;
86
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -140,6 +138,147 @@ public ResponseEntity<MemberResponse> getMyInfo(Authentication authentication) {
140138
return ResponseEntity.ok(response);
141139
}
142140

141+
@PostMapping("/sendEmail")
142+
@Operation(summary = "이메일 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId(이메일)로 인증번호를 받습니다.")
143+
@ApiResponses({
144+
@ApiResponse(responseCode = "200", description = "이메일 전송 성공"),
145+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (loginId 없음)")
146+
})
147+
public ResponseEntity<EmailResponse> sendEmail(
148+
@RequestBody(required = false) MemberEmailRequestDto requestDto,
149+
Authentication authentication,
150+
HttpServletRequest request) {
151+
152+
String loginId = null;
153+
154+
// 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1)
155+
if (authentication != null && authentication.isAuthenticated() &&
156+
!"anonymousUser".equals(authentication.getPrincipal())) {
157+
158+
// JWT 토큰에서 직접 loginid claim 추출
159+
try {
160+
String token = extractAccessTokenFromRequest(request);
161+
if (token != null) {
162+
loginId = memberService.extractLoginIdFromToken(token);
163+
if (loginId != null) {
164+
log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId);
165+
} else {
166+
log.warn("JWT 토큰에서 loginId 추출 실패");
167+
}
168+
}
169+
} catch (Exception e) {
170+
log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage());
171+
}
172+
}
173+
174+
// 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2)
175+
if (loginId == null) {
176+
if (requestDto != null && requestDto.getLoginId() != null && !requestDto.getLoginId().isBlank()) {
177+
loginId = requestDto.getLoginId();
178+
log.info("요청 바디에서 loginId 추출 성공: {}", loginId);
179+
} else {
180+
log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음");
181+
throw new IllegalArgumentException("인증번호를 전송할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요.");
182+
}
183+
}
184+
185+
try {
186+
// 서비스 호출
187+
memberService.sendCodeToEmailByLoginId(loginId);
188+
log.info("이메일 인증번호 전송 성공: {}", loginId);
189+
return ResponseEntity.ok(EmailResponse.success("이메일 전송 성공", loginId));
190+
191+
} catch (IllegalArgumentException e) {
192+
log.error("이메일 전송 실패 - 존재하지 않는 회원: {}", loginId);
193+
throw e;
194+
} catch (Exception e) {
195+
log.error("이메일 전송 실패: loginId={}, error={}", loginId, e.getMessage());
196+
throw new RuntimeException("이메일 전송 중 오류가 발생했습니다.");
197+
}
198+
}
199+
200+
@PostMapping("/verifyEmail")
201+
@Operation(summary = "이메일 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.")
202+
@ApiResponses({
203+
@ApiResponse(responseCode = "200", description = "인증번호 검증 성공"),
204+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (인증번호 불일치, loginId 없음)")
205+
})
206+
public ResponseEntity<EmailResponse> verifyEmail(
207+
@RequestBody @Valid EmailVerifyCodeRequestDto requestDto,
208+
Authentication authentication,
209+
HttpServletRequest request) {
210+
211+
String loginId = null;
212+
213+
// 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1)
214+
if (authentication != null && authentication.isAuthenticated() &&
215+
!"anonymousUser".equals(authentication.getPrincipal())) {
216+
217+
// JWT 토큰에서 직접 loginid claim 추출
218+
try {
219+
String token = extractAccessTokenFromRequest(request);
220+
if (token != null) {
221+
loginId = memberService.extractLoginIdFromToken(token);
222+
if (loginId != null) {
223+
log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId);
224+
} else {
225+
log.warn("JWT 토큰에서 loginId 추출 실패");
226+
}
227+
}
228+
} catch (Exception e) {
229+
log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage());
230+
}
231+
}
232+
233+
// 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2)
234+
if (loginId == null) {
235+
if (requestDto.getLoginId() != null && !requestDto.getLoginId().isBlank()) {
236+
loginId = requestDto.getLoginId();
237+
log.info("요청 바디에서 loginId 추출 성공: {}", loginId);
238+
} else {
239+
log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음");
240+
throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요.");
241+
}
242+
}
243+
244+
try {
245+
// 서비스 호출 - 인증번호 검증
246+
boolean isValid = memberService.verifyAuthCode(loginId, requestDto.getVerificationCode());
247+
248+
if (isValid) {
249+
log.info("이메일 인증번호 검증 성공: {}", loginId);
250+
return ResponseEntity.ok(EmailResponse.success("인증번호 검증 성공", loginId));
251+
} else {
252+
log.error("이메일 인증번호 검증 실패 - 잘못된 인증번호: {}", loginId);
253+
throw new IllegalArgumentException("잘못된 인증번호이거나 만료된 인증번호입니다.");
254+
}
255+
256+
} catch (IllegalArgumentException e) {
257+
log.error("이메일 인증번호 검증 실패: loginId={}, error={}", loginId, e.getMessage());
258+
throw e;
259+
} catch (Exception e) {
260+
log.error("이메일 인증번호 검증 중 오류 발생: loginId={}, error={}", loginId, e.getMessage());
261+
throw new RuntimeException("인증번호 검증 중 오류가 발생했습니다.");
262+
}
263+
}
264+
265+
// ===== 비밀번호 재설정 엔드포인트 =====
266+
267+
@PostMapping("/password-reset/reset")
268+
@Operation(summary = "비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.")
269+
@ApiResponses({
270+
@ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"),
271+
@ApiResponse(responseCode = "400", description = "인증되지 않았거나 잘못된 요청")
272+
})
273+
public ResponseEntity<PasswordResetResponse> resetPassword(@Valid @RequestBody ResetPasswordRequestDto request) {
274+
log.info("비밀번호 재설정 요청: email={}", request.getLoginId());
275+
276+
memberService.resetPassword(request.getLoginId(), request.getNewPassword(), request.getSuccess());
277+
278+
log.info("비밀번호 재설정 성공: email={}", request.getLoginId());
279+
return ResponseEntity.ok(PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", request.getLoginId()));
280+
}
281+
143282
/**
144283
* HTTP 쿠키에서 리프레시 토큰을 추출합니다.
145284
* @param request HTTP 요청 객체
@@ -155,4 +294,27 @@ private String extractRefreshTokenFromCookies(HttpServletRequest request) {
155294
}
156295
return null;
157296
}
297+
298+
/**
299+
* HTTP 쿠키에서 액세스 토큰을 추출합니다.
300+
* @param request HTTP 요청 객체
301+
* @return 액세스 토큰 값 또는 null
302+
*/
303+
private String extractAccessTokenFromRequest(HttpServletRequest request) {
304+
// 1. Authorization 헤더에서 추출 시도
305+
String authHeader = request.getHeader("Authorization");
306+
if (authHeader != null && authHeader.startsWith("Bearer ")) {
307+
return authHeader.substring(7);
308+
}
309+
310+
// 2. 쿠키에서 추출 시도
311+
if (request.getCookies() != null) {
312+
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
313+
if ("accessToken".equals(cookie.getName())) {
314+
return cookie.getValue();
315+
}
316+
}
317+
}
318+
return null;
319+
}
158320
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import lombok.*;
4+
5+
import java.time.LocalDateTime;
6+
7+
@Getter
8+
@Setter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Builder
12+
public class EmailResponse {
13+
private String message;
14+
private String email;
15+
private LocalDateTime timestamp;
16+
private boolean success;
17+
18+
public static EmailResponse success(String message, String email) {
19+
return EmailResponse.builder()
20+
.message(message)
21+
.email(email)
22+
.success(true)
23+
.timestamp(LocalDateTime.now())
24+
.build();
25+
}
26+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Pattern;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class EmailVerifyCodeRequestDto {
11+
// 선택적 필드 - JWT 토큰이 있으면 불필요, 없으면 필수
12+
private String loginId;
13+
14+
@NotBlank(message = "인증번호를 입력해주세요.")
15+
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.")
16+
private String verificationCode;
17+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
6+
@Getter
7+
@Setter
8+
public class MemberEmailRequestDto {
9+
private String loginId;
10+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import lombok.*;
4+
5+
import java.time.LocalDateTime;
6+
7+
@Getter
8+
@Setter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Builder
12+
public class PasswordResetResponse {
13+
private String message;
14+
private String email;
15+
private LocalDateTime timestamp;
16+
private boolean success;
17+
18+
public static PasswordResetResponse success(String message, String email) {
19+
return PasswordResetResponse.builder()
20+
.message(message)
21+
.email(email)
22+
.success(true)
23+
.timestamp(LocalDateTime.now())
24+
.build();
25+
}
26+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class ResetPasswordRequestDto {
11+
@NotBlank(message = "이메일을 입력해주세요.")
12+
private String loginId;
13+
14+
@NotBlank(message = "새 비밀번호를 입력해주세요.")
15+
private String newPassword;
16+
17+
@NotNull(message = "인증 성공 여부가 필요합니다.")
18+
private Boolean success;
19+
}

backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,8 @@ public enum Role {
7777
private final String description;
7878
Role(String description) { this.description = description; }
7979
}
80+
81+
public void updatePassword(String newPassword) {
82+
this.password = newPassword;
83+
}
8084
}

0 commit comments

Comments
 (0)