Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public ResponseEntity<MemberResponse> login(@Valid @RequestBody MemberLoginReque
}

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

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

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

String loginId = null;
Authentication authentication
) {

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

// JWT 토큰에서 직접 loginid claim 추출
try {
String token = extractAccessTokenFromRequest(request);
if (token != null) {
loginId = memberService.extractLoginIdFromToken(token);
if (loginId != null) {
log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId);
} else {
log.warn("JWT 토큰에서 loginId 추출 실패");
}
}
} catch (Exception e) {
log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage());
}
log.error("로그인된 사용자의 이메일 인증 시도");
throw new IllegalArgumentException("로그인된 사용자는 비밀번호 검증을 사용해야 합니다.");
}

// 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2)
if (loginId == null) {
if (requestDto.getLoginId() != null && !requestDto.getLoginId().isBlank()) {
loginId = requestDto.getLoginId();
log.info("요청 바디에서 loginId 추출 성공: {}", loginId);
} else {
log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음");
throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요.");
}
if (requestDto.getLoginId() == null || requestDto.getLoginId().isBlank()) {
log.error("요청 바디에 loginId가 없음");
throw new IllegalArgumentException("인증번호를 검증할 이메일 주소가 필요합니다.");
}

String loginId = requestDto.getLoginId();
log.info("이메일 인증번호 검증 요청: {}", loginId);

try {
// 서비스 호출 - 인증번호 검증
boolean isValid = memberService.verifyAuthCode(loginId, requestDto.getVerificationCode());

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

// ===== 비밀번호 재설정 엔드포인트 =====
@PostMapping("/verifyPassword")
@Operation(summary = "07. 비밀번호 검증", description = "로그인된 사용자가 비밀번호를 통해 인증합니다. (비밀번호 재설정용)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "비밀번호 검증 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (비밀번호 불일치)"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자")
})
public ResponseEntity<VerificationResponse> verifyPassword(
@RequestBody @Valid PasswordVerifyRequestDto requestDto,
Authentication authentication,
HttpServletRequest request){

if (authentication == null || !authentication.isAuthenticated() ||
"anonymousUser".equals(authentication.getPrincipal())) {
log.error("비로그인 사용자의 비밀번호 검증 시도");
throw new IllegalArgumentException("비밀번호 검증은 로그인된 사용자만 가능합니다. 비로그인 사용자는 이메일 인증을 사용하세요.");
}

String loginId = null;
try {
String token = extractAccessTokenFromRequest(request);
if (token != null) {
loginId = memberService.extractLoginIdFromToken(token);
}
} catch (Exception e) {
log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage());
}

if (loginId == null) {
log.error("JWT 토큰에서 loginId 추출 실패");
throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
}

log.info("비밀번호 검증 요청: {}", loginId);

try {
boolean isValid = memberService.verifyPassword(loginId, requestDto.getPassword());

if (isValid) {
log.info("비밀번호 검증 성공: {}", loginId);
return ResponseEntity.ok(VerificationResponse.success("비밀번호 검증 성공", loginId));
} else {
log.error("비밀번호 검증 실패 - 비밀번호 불일치: {}", loginId);
throw new IllegalArgumentException("잘못된 입력입니다.");
}

} catch (IllegalArgumentException e) {
log.error("비밀번호 검증 실패: loginId={}, error={}", loginId, e.getMessage());
throw e;
} catch (Exception e) {
log.error("비밀번호 검증 중 오류 발생: loginId={}, error={}", loginId, e.getMessage());
throw new RuntimeException("비밀번호 검증 중 오류가 발생했습니다.");
}
}

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

String loginId = null;

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

Expand Down Expand Up @@ -341,7 +374,8 @@ private String extractRefreshTokenFromCookies(HttpServletRequest request) {
}

/**
* HTTP 쿠키에서 액세스 토큰을 추출합니다.
* HTTP 요청에서 액세스 토큰을 추출합니다.
* Authorization 헤더 또는 쿠키에서 토큰을 확인합니다.
* @param request HTTP 요청 객체
* @return 액세스 토큰 값 또는 null
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
@Getter
@Setter
public class EmailVerifyCodeRequestDto {
// 선택적 필드 - JWT 토큰이 있으면 불필요, 없으면 필수
private String loginId;

@NotBlank(message = "인증번호를 입력해주세요.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ai.lawyer.domain.member.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class PasswordVerifyRequestDto {
String password;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.ai.lawyer.domain.member.dto;

import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VerificationResponse {
private String message;
private String email;
private LocalDateTime timestamp;
private boolean success;

public static VerificationResponse success(String message, String email) {
return VerificationResponse.builder()
.message(message)
.email(email)
.success(true)
.timestamp(LocalDateTime.now())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public class Member {
@Column(name = "loginid", nullable = false, unique = true, length = 100)
@Email(message = "올바른 이메일 형식이 아닙니다")
@NotBlank(message = "이메일(로그인 ID)은 필수입니다")
private String loginId; // 반드시 이메일 형식
private String loginId;

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

@UpdateTimestamp
@Column(name = "updated_at") // nullable = true (유일하게 null 허용)
@Column(name = "updated_at")
private LocalDateTime updatedAt;

// Enums
@Getter
public enum Gender {
MALE("남성"), FEMALE("여성"), OTHER("기타");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ public MemberResponse signup(MemberSignupRequest request, HttpServletResponse re

Member savedMember = memberRepository.save(member);

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

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

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

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

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

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

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

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

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

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

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

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

// 인증번호 검증
return emailAuthService.verifyAuthCode(loginId, verificationCode);
}

public boolean verifyPassword(String loginId, String password) {
Member member = memberRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));

return passwordEncoder.matches(password, member.getPassword());
}

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

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

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

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

// 비밀번호 변경
String encodedPassword = passwordEncoder.encode(newPassword);
member.updatePassword(encodedPassword);
memberRepository.save(member);

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

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

/**
* JWT 토큰에서 loginId 추출
*/
public String extractLoginIdFromToken(String token) {
return tokenProvider.getLoginIdFromToken(token);
}

/**
* 로그인 ID 중복 검사
* @param loginId 검사할 로그인 ID
* @throws IllegalArgumentException 중복된 로그인 ID인 경우
*/
private void validateDuplicateLoginId(String loginId) {
if (memberRepository.existsByLoginId(loginId)) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
Expand Down
Loading
Loading