Skip to content

Commit 4265c54

Browse files
authored
Merge pull request #159 from prgrms-web-devcourse-final-project/feat/154-member
Feat[member]: 비밀번호 재설정을 위한 로그인 검증 로직 추가 및 로직추가에 따른 테스트 코드 수정
2 parents 1e109a6 + 6930e9e commit 4265c54

File tree

13 files changed

+1291
-85
lines changed

13 files changed

+1291
-85
lines changed

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

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public ResponseEntity<MemberResponse> login(@Valid @RequestBody MemberLoginReque
5555
}
5656

5757
@PostMapping("/logout")
58-
@Operation(summary = "08. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
58+
@Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
5959
@ApiResponses({
6060
@ApiResponse(responseCode = "200", description = "로그아웃 성공")
6161
})
@@ -98,7 +98,7 @@ public ResponseEntity<MemberResponse> refreshToken(HttpServletRequest request,
9898
}
9999

100100
@DeleteMapping("/withdraw")
101-
@Operation(summary = "09. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.")
101+
@Operation(summary = "10. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.")
102102
@ApiResponses({
103103
@ApiResponse(responseCode = "200", description = "회원탈퇴 성공"),
104104
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
@@ -198,56 +198,37 @@ public ResponseEntity<EmailResponse> sendEmail(
198198
}
199199

200200
@PostMapping("/verifyEmail")
201-
@Operation(summary = "06. 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.")
201+
@Operation(summary = "06. 인증번호 검증", description = "비로그인 사용자가 이메일로 받은 인증번호를 검증합니다. (비밀번호 재설정용)")
202202
@ApiResponses({
203203
@ApiResponse(responseCode = "200", description = "인증번호 검증 성공"),
204204
@ApiResponse(responseCode = "400", description = "잘못된 요청 (인증번호 불일치, loginId 없음)")
205205
})
206-
public ResponseEntity<EmailResponse> verifyEmail(
206+
public ResponseEntity<VerificationResponse> verifyEmail(
207207
@RequestBody @Valid EmailVerifyCodeRequestDto requestDto,
208-
Authentication authentication,
209-
HttpServletRequest request) {
210-
211-
String loginId = null;
208+
Authentication authentication
209+
) {
212210

213-
// 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1)
214211
if (authentication != null && authentication.isAuthenticated() &&
215212
!"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-
}
213+
log.error("로그인된 사용자의 이메일 인증 시도");
214+
throw new IllegalArgumentException("로그인된 사용자는 비밀번호 검증을 사용해야 합니다.");
231215
}
232216

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-
}
217+
if (requestDto.getLoginId() == null || requestDto.getLoginId().isBlank()) {
218+
log.error("요청 바디에 loginId가 없음");
219+
throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다.");
242220
}
243221

222+
String loginId = requestDto.getLoginId();
223+
log.info("이메일 인증번호 검증 요청: {}", loginId);
224+
244225
try {
245226
// 서비스 호출 - 인증번호 검증
246227
boolean isValid = memberService.verifyAuthCode(loginId, requestDto.getVerificationCode());
247228

248229
if (isValid) {
249230
log.info("이메일 인증번호 검증 성공: {}", loginId);
250-
return ResponseEntity.ok(EmailResponse.success("인증번호 검증 성공", loginId));
231+
return ResponseEntity.ok(VerificationResponse.success("인증번호 검증 성공", loginId));
251232
} else {
252233
log.error("이메일 인증번호 검증 실패 - 잘못된 인증번호: {}", loginId);
253234
throw new IllegalArgumentException("잘못된 인증번호이거나 만료된 인증번호입니다.");
@@ -262,10 +243,63 @@ public ResponseEntity<EmailResponse> verifyEmail(
262243
}
263244
}
264245

265-
// ===== 비밀번호 재설정 엔드포인트 =====
246+
@PostMapping("/verifyPassword")
247+
@Operation(summary = "07. 비밀번호 검증", description = "로그인된 사용자가 비밀번호를 통해 인증합니다. (비밀번호 재설정용)")
248+
@ApiResponses({
249+
@ApiResponse(responseCode = "200", description = "비밀번호 검증 성공"),
250+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (비밀번호 불일치)"),
251+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자")
252+
})
253+
public ResponseEntity<VerificationResponse> verifyPassword(
254+
@RequestBody @Valid PasswordVerifyRequestDto requestDto,
255+
Authentication authentication,
256+
HttpServletRequest request){
257+
258+
if (authentication == null || !authentication.isAuthenticated() ||
259+
"anonymousUser".equals(authentication.getPrincipal())) {
260+
log.error("비로그인 사용자의 비밀번호 검증 시도");
261+
throw new IllegalArgumentException("비밀번호 검증은 로그인된 사용자만 가능합니다. 비로그인 사용자는 이메일 인증을 사용하세요.");
262+
}
263+
264+
String loginId = null;
265+
try {
266+
String token = extractAccessTokenFromRequest(request);
267+
if (token != null) {
268+
loginId = memberService.extractLoginIdFromToken(token);
269+
}
270+
} catch (Exception e) {
271+
log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage());
272+
}
273+
274+
if (loginId == null) {
275+
log.error("JWT 토큰에서 loginId 추출 실패");
276+
throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
277+
}
278+
279+
log.info("비밀번호 검증 요청: {}", loginId);
280+
281+
try {
282+
boolean isValid = memberService.verifyPassword(loginId, requestDto.getPassword());
283+
284+
if (isValid) {
285+
log.info("비밀번호 검증 성공: {}", loginId);
286+
return ResponseEntity.ok(VerificationResponse.success("비밀번호 검증 성공", loginId));
287+
} else {
288+
log.error("비밀번호 검증 실패 - 비밀번호 불일치: {}", loginId);
289+
throw new IllegalArgumentException("잘못된 입력입니다.");
290+
}
291+
292+
} catch (IllegalArgumentException e) {
293+
log.error("비밀번호 검증 실패: loginId={}, error={}", loginId, e.getMessage());
294+
throw e;
295+
} catch (Exception e) {
296+
log.error("비밀번호 검증 중 오류 발생: loginId={}, error={}", loginId, e.getMessage());
297+
throw new RuntimeException("비밀번호 검증 중 오류가 발생했습니다.");
298+
}
299+
}
266300

267301
@PostMapping("/password-reset/reset")
268-
@Operation(summary = "07. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.")
302+
@Operation(summary = "08. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.")
269303
@ApiResponses({
270304
@ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"),
271305
@ApiResponse(responseCode = "400", description = "인증되지 않았거나 잘못된 요청")
@@ -285,7 +319,6 @@ public ResponseEntity<PasswordResetResponse> resetPassword(
285319

286320
String loginId = null;
287321

288-
// 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1)
289322
if (authentication != null && authentication.isAuthenticated() &&
290323
!"anonymousUser".equals(authentication.getPrincipal())) {
291324

@@ -341,7 +374,8 @@ private String extractRefreshTokenFromCookies(HttpServletRequest request) {
341374
}
342375

343376
/**
344-
* HTTP 쿠키에서 액세스 토큰을 추출합니다.
377+
* HTTP 요청에서 액세스 토큰을 추출합니다.
378+
* Authorization 헤더 또는 쿠키에서 토큰을 확인합니다.
345379
* @param request HTTP 요청 객체
346380
* @return 액세스 토큰 값 또는 null
347381
*/

backend/src/main/java/com/ai/lawyer/domain/member/dto/EmailVerifyCodeRequestDto.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
@Getter
99
@Setter
1010
public class EmailVerifyCodeRequestDto {
11-
// 선택적 필드 - JWT 토큰이 있으면 불필요, 없으면 필수
1211
private String loginId;
1312

1413
@NotBlank(message = "인증번호를 입력해주세요.")
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 PasswordVerifyRequestDto {
9+
String password;
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 VerificationResponse {
13+
private String message;
14+
private String email;
15+
private LocalDateTime timestamp;
16+
private boolean success;
17+
18+
public static VerificationResponse success(String message, String email) {
19+
return VerificationResponse.builder()
20+
.message(message)
21+
.email(email)
22+
.success(true)
23+
.timestamp(LocalDateTime.now())
24+
.build();
25+
}
26+
}

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public class Member {
3030
@Column(name = "loginid", nullable = false, unique = true, length = 100)
3131
@Email(message = "올바른 이메일 형식이 아닙니다")
3232
@NotBlank(message = "이메일(로그인 ID)은 필수입니다")
33-
private String loginId; // 반드시 이메일 형식
33+
private String loginId;
3434

3535
@Column(name = "password", nullable = false)
3636
@NotBlank(message = "비밀번호는 필수입니다")
@@ -60,10 +60,9 @@ public class Member {
6060
private LocalDateTime createdAt;
6161

6262
@UpdateTimestamp
63-
@Column(name = "updated_at") // nullable = true (유일하게 null 허용)
63+
@Column(name = "updated_at")
6464
private LocalDateTime updatedAt;
6565

66-
// Enums
6766
@Getter
6867
public enum Gender {
6968
MALE("남성"), FEMALE("여성"), OTHER("기타");

backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ public MemberResponse signup(MemberSignupRequest request, HttpServletResponse re
4040

4141
Member savedMember = memberRepository.save(member);
4242

43-
// 회원가입 후 자동 로그인: JWT 토큰 생성 및 쿠키 설정
4443
String accessToken = tokenProvider.generateAccessToken(savedMember);
4544
String refreshToken = tokenProvider.generateRefreshToken(savedMember);
4645
cookieUtil.setTokenCookies(response, accessToken, refreshToken);
@@ -56,7 +55,6 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp
5655
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
5756
}
5857

59-
// JWT 액세스 토큰과 리프레시 토큰 생성 후 HTTP 쿠키에 설정
6058
String accessToken = tokenProvider.generateAccessToken(member);
6159
String refreshToken = tokenProvider.generateRefreshToken(member);
6260
cookieUtil.setTokenCookies(response, accessToken, refreshToken);
@@ -65,39 +63,31 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp
6563
}
6664

6765
public void logout(String loginId, HttpServletResponse response) {
68-
// 로그인 ID가 존재할 경우 Redis에서 모든 토큰 삭제
6966
if (loginId != null && !loginId.trim().isEmpty()) {
7067
tokenProvider.deleteAllTokens(loginId);
7168
}
7269

73-
// 인증 상태와 관계없이 클라이언트 쿠키 클리어
7470
cookieUtil.clearTokenCookies(response);
7571
}
7672

7773
public MemberResponse refreshToken(String refreshToken, HttpServletResponse response) {
78-
// Redis에서 리프레시 토큰으로 사용자 찾기
7974
String loginId = tokenProvider.findUsernameByRefreshToken(refreshToken);
8075
if (loginId == null) {
8176
throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
8277
}
8378

84-
// 리프레시 토큰 유효성 검증
8579
if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) {
8680
throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.");
8781
}
8882

89-
// 회원 정보 조회
9083
Member member = memberRepository.findByLoginId(loginId)
9184
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
9285

93-
// RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제
9486
tokenProvider.deleteAllTokens(loginId);
9587

96-
// 새로운 액세스 토큰과 리프레시 토큰 생성
9788
String newAccessToken = tokenProvider.generateAccessToken(member);
9889
String newRefreshToken = tokenProvider.generateRefreshToken(member);
9990

100-
// 새로운 토큰들을 HTTP 쿠키에 설정
10191
cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken);
10292

10393
return MemberResponse.from(member);
@@ -121,70 +111,53 @@ public MemberResponse getMemberById(Long memberId) {
121111
public void sendCodeToEmailByLoginId(String loginId) {
122112
Member member = memberRepository.findByLoginId(loginId)
123113
.orElseThrow(() -> new IllegalArgumentException("해당 로그인 ID의 회원이 없습니다."));
124-
String email = member.getLoginId(); // loginId가 이메일이므로 바로 사용
125-
emailService.sendVerificationCode(email, loginId); // Redis에 저장 + 메일 전송
114+
String email = member.getLoginId();
115+
emailService.sendVerificationCode(email, loginId);
126116
}
127117

128-
/**
129-
* 이메일 인증번호 검증 (일반 용도)
130-
*/
131118
public boolean verifyAuthCode(String loginId, String verificationCode) {
132-
// 회원 존재 여부 확인
133119
memberRepository.findByLoginId(loginId)
134120
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
135121

136-
// 인증번호 검증
137122
return emailAuthService.verifyAuthCode(loginId, verificationCode);
138123
}
139124

125+
public boolean verifyPassword(String loginId, String password) {
126+
Member member = memberRepository.findByLoginId(loginId)
127+
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
128+
129+
return passwordEncoder.matches(password, member.getPassword());
130+
}
140131

141-
/**
142-
* 비밀번호 재설정 실행
143-
*/
144132
@Transactional
145133
public void resetPassword(String loginId, String newPassword, Boolean success) {
146-
// 회원 존재 여부 확인
147134
Member member = memberRepository.findByLoginId(loginId)
148135
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
149136

150-
// 클라이언트에서 전달한 success 값과 Redis의 인증 성공 여부를 모두 확인
151137
boolean clientSuccess = Boolean.TRUE.equals(success);
152138

153-
// 클라이언트 success가 false면 바로 실패
154139
if (!clientSuccess) {
155140
throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다.");
156141
}
157142

158-
// 클라이언트 success가 true면 Redis 인증 상태도 확인
159143
boolean redisVerified = emailAuthService.isEmailVerified(loginId);
160144
if (!redisVerified) {
161145
throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다.");
162146
}
163147

164-
// 비밀번호 변경
165148
String encodedPassword = passwordEncoder.encode(newPassword);
166149
member.updatePassword(encodedPassword);
167150
memberRepository.save(member);
168151

169-
// 인증 데이터 삭제 (비밀번호 재설정 완료 후)
170152
emailAuthService.clearAuthData(loginId);
171153

172-
// 기존 모든 토큰 삭제 (보안상 로그아웃 처리)
173154
tokenProvider.deleteAllTokens(loginId);
174155
}
175156

176-
/**
177-
* JWT 토큰에서 loginId 추출
178-
*/
179157
public String extractLoginIdFromToken(String token) {
180158
return tokenProvider.getLoginIdFromToken(token);
181159
}
182160

183-
/**
184-
* 로그인 ID 중복 검사
185-
* @param loginId 검사할 로그인 ID
186-
* @throws IllegalArgumentException 중복된 로그인 ID인 경우
187-
*/
188161
private void validateDuplicateLoginId(String loginId) {
189162
if (memberRepository.existsByLoginId(loginId)) {
190163
throw new IllegalArgumentException("이미 존재하는 이메일입니다.");

backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public void setTokenCookies(HttpServletResponse response, String accessToken, St
2121
public void setAccessTokenCookie(HttpServletResponse response, String accessToken) {
2222
Cookie accessCookie = new Cookie(ACCESS_TOKEN_NAME, accessToken);
2323
accessCookie.setHttpOnly(true);
24-
accessCookie.setSecure(false); // TODO: 운영환경에서는 true로 변경 (HTTPS)
24+
accessCookie.setSecure(false); // 운영환경에서는 true로 변경 (HTTPS)
2525
accessCookie.setPath("/");
2626
accessCookie.setMaxAge(ACCESS_TOKEN_EXPIRE_TIME);
2727
response.addCookie(accessCookie);
@@ -30,7 +30,7 @@ public void setAccessTokenCookie(HttpServletResponse response, String accessToke
3030
public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
3131
Cookie refreshCookie = new Cookie(REFRESH_TOKEN_NAME, refreshToken);
3232
refreshCookie.setHttpOnly(true);
33-
refreshCookie.setSecure(false); // TODO: 운영환경에서는 true로 변경 (HTTPS)
33+
refreshCookie.setSecure(false); // 운영환경에서는 true로 변경 (HTTPS)
3434
refreshCookie.setPath("/");
3535
refreshCookie.setMaxAge(REFRESH_TOKEN_EXPIRE_TIME);
3636
response.addCookie(refreshCookie);

0 commit comments

Comments
 (0)