Skip to content

Commit d8cbe27

Browse files
authored
Merge pull request #102 from UMCSCOI/Feat/#89
🐛 [Bug] Auth 도메인 버그 수정
2 parents 8217f92 + 5abb76e commit d8cbe27

13 files changed

Lines changed: 168 additions & 29 deletions

File tree

src/main/java/com/example/scoi/domain/auth/controller/AuthController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ public ApiResponse<AuthResDTO.ReissueResponse> reissue(
9393
return ApiResponse.onSuccess(AuthSuccessCode.TOKEN_REISSUED, response);
9494
}
9595

96+
@Operation(summary = "비인증 간편비밀번호 재설정 By 장명준", description = "계정 잠금 후 SMS 재인증으로 간편비밀번호를 재설정합니다.")
97+
@PostMapping("/password/reset")
98+
public ApiResponse<Void> resetPassword(
99+
@Valid @RequestBody AuthReqDTO.PasswordResetRequest request
100+
) {
101+
authService.resetPassword(request);
102+
return ApiResponse.onSuccess(AuthSuccessCode.PASSWORD_RESET_SUCCESS);
103+
}
104+
96105
@Operation(summary = "로그아웃 By 장명준", description = "로그아웃 처리 (Refresh Token 삭제, Access Token 블랙리스트 등록)")
97106
@PostMapping("/logout")
98107
public ApiResponse<Void> logout(

src/main/java/com/example/scoi/domain/auth/dto/AuthReqDTO.java

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,45 +16,53 @@ public class AuthReqDTO {
1616
public record SmsSendRequest(
1717
@NotBlank(message = "휴대폰 번호는 필수입니다.")
1818
@Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.")
19+
@Schema(description = "휴대폰 번호", example = "01012345678")
1920
String phoneNumber
2021
) {}
2122

2223
// SMS 검증 요청
2324
public record SmsVerifyRequest(
2425
@NotBlank(message = "휴대폰 번호는 필수입니다.")
2526
@Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.")
27+
@Schema(description = "휴대폰 번호", example = "01012345678")
2628
String phoneNumber,
2729

2830
@NotBlank(message = "인증번호는 필수입니다.")
29-
@Size(min = 6, max = 6, message = "인증번호는 6자리입니다.")
31+
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자입니다.")
32+
@Schema(description = "SMS 인증번호 6자리", example = "123456")
3033
String verificationCode
3134
) {}
3235

3336
// 회원가입 요청
3437
public record SignupRequest(
3538
@NotBlank(message = "휴대폰 번호는 필수입니다.")
3639
@Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.")
40+
@Schema(description = "휴대폰 번호", example = "01012345678")
3741
String phoneNumber,
3842

3943
@NotBlank(message = "인증 토큰은 필수입니다.")
44+
@Schema(description = "SMS 인증 완료 후 발급된 verificationToken")
4045
String verificationToken,
4146

4247
@NotBlank(message = "영문 이름은 필수입니다.")
4348
@Pattern(regexp = "^[A-Z ]+$", message = "영문 대문자와 공백만 입력 가능합니다.")
4449
@Size(max = 50, message = "영문 이름은 50자 이내입니다.")
50+
@Schema(description = "영문 이름 (대문자)", example = "JANG MYONGJUN")
4551
String englishName,
4652

4753
@NotBlank(message = "한글 이름은 필수입니다.")
4854
@Pattern(regexp = "^[가-힣]+$", message = "한글만 입력 가능합니다.")
4955
@Size(min = 2, max = 5, message = "한글 이름은 2~5자입니다.")
56+
@Schema(description = "한글 이름", example = "장명준")
5057
String koreanName,
5158

5259
@NotBlank(message = "주민등록번호는 필수입니다.")
53-
@Pattern(regexp = "^\\d{6}-\\d{7}$", message = "올바른 주민등록번호 형식이 아닙니다.")
60+
@Pattern(regexp = "^\\d{7}$", message = "올바른 주민등록번호 형식이 아닙니다.")
61+
@Schema(description = "주민등록번호 앞 7자리 (생년월일 6자리 + 성별코드 1자리)", example = "0306203")
5462
String residentNumber,
5563

5664
@NotBlank(message = "간편비밀번호는 필수입니다.")
57-
@Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "ItfrsoB1J0hl3O60mahB1A==")
65+
@Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==")
5866
String simplePassword,
5967

6068
@Schema(description = "회원 타입 (미입력 시 INDIVIDUAL 기본값)",
@@ -76,31 +84,48 @@ public record ApiKeyRequest(
7684
ExchangeType exchangeType,
7785

7886
@NotBlank(message = "퍼블릭 키는 필수입니다.")
79-
@Schema(description = "거래소 API 퍼블릭 키", example = "your-public-key")
87+
@Schema(description = "거래소 API 퍼블릭 키", example = "abcdef1234567890abcdef12")
8088
String publicKey,
8189

8290
@NotBlank(message = "시크릿 키는 필수입니다.")
83-
@Schema(description = "거래소 API 시크릿 키 (AES 암호화된 Base64)", example = "asdadsasdasd...")
91+
@Schema(description = "거래소 API 시크릿 키 (AES 암호화된 Base64)", example = "abcdef1234567890abcdef1234567890")
8492
String secretKey
8593
) {}
8694

8795
// 로그인 요청
8896
public record LoginRequest(
8997
@NotBlank(message = "휴대폰 번호는 필수입니다.")
9098
@Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.")
99+
@Schema(description = "휴대폰 번호", example = "01012345678")
91100
String phoneNumber,
92101

93102
@NotBlank(message = "간편비밀번호는 필수입니다.")
94-
@Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "ItfrsoB1J0hl3O60mahB1A==")
103+
@Schema(description = "AES 암호화된 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==")
95104
String simplePassword,
96105

97106
@Schema(description = "SMS 재인증 토큰 (계정 잠금/RT 만료 시 필수, 일반 로그인 시 생략)", nullable = true)
98107
String verificationToken
99108
) {}
100109

110+
// 비인증 간편비밀번호 재설정 요청 (계정 잠금 후 SMS 재인증 flow)
111+
public record PasswordResetRequest(
112+
@NotBlank(message = "휴대폰 번호는 필수입니다.")
113+
@Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.")
114+
@Schema(description = "휴대폰 번호", example = "01012345678")
115+
String phoneNumber,
116+
117+
@NotBlank(message = "인증 토큰은 필수입니다.")
118+
@Schema(description = "SMS 인증 완료 후 발급된 verificationToken")
119+
String verificationToken,
120+
121+
@NotBlank(message = "새 간편비밀번호는 필수입니다.")
122+
@Schema(description = "AES 암호화된 새 6자리 간편비밀번호 (Base64)", example = "6v4RsQ+gOGi1NtheSTiA1w==")
123+
String newPassword
124+
) {}
125+
101126
// 토큰 재발급 요청
102127
public record ReissueRequest(
103128
@NotBlank(message = "Refresh Token은 필수입니다.")
104129
String refreshToken
105130
) {}
106-
}
131+
}

src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public record SmsSendResponse(
1515

1616
// SMS 검증 응답
1717
public record SmsVerifyResponse(
18-
String verificationToken // 인증 성공 시 발급되는 일회용 토큰 (유효시간: 10분)
18+
String verificationToken, // 인증 성공 시 발급되는 일회용 토큰 (유효시간: 10분)
19+
boolean isExistingMember // 기존 회원 여부 (true: 기존 회원 → 간편비밀번호 설정, false: 신규 회원 → 회원가입)
1920
) {}
2021

2122
// 회원가입 응답

src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public enum AuthErrorCode implements BaseErrorCode {
4545

4646
// 토큰 관련
4747
UNAUTHORIZED(HttpStatus.UNAUTHORIZED,
48-
"AUTH401_1",
48+
"AUTH401_0",
4949
"인증이 필요합니다."),
5050
INVALID_TOKEN(HttpStatus.UNAUTHORIZED,
5151
"AUTH401_2",

src/main/java/com/example/scoi/domain/auth/exception/code/AuthSuccessCode.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public enum AuthSuccessCode implements BaseSuccessCode {
2727
LOGOUT_SUCCESS(HttpStatus.OK,
2828
"AUTH200_5",
2929
"로그아웃되었습니다."),
30+
PASSWORD_RESET_SUCCESS(HttpStatus.OK,
31+
"AUTH200_6",
32+
"비밀번호가 재설정되었습니다."),
3033
;
3134

3235
private final HttpStatus status;

src/main/java/com/example/scoi/domain/auth/service/AuthService.java

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,11 @@ public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest reques
141141
String tokenKey = VERIFICATION_PREFIX + verificationToken;
142142
redisUtil.set(tokenKey, request.phoneNumber(), VERIFICATION_EXPIRATION_MINUTES, TimeUnit.MINUTES);
143143

144-
log.info("SMS 인증 성공: phoneNumber={}", request.phoneNumber());
145-
return new AuthResDTO.SmsVerifyResponse(verificationToken);
144+
// 6. 기존 회원 여부 확인 (화면 분기용)
145+
boolean isExistingMember = memberRepository.existsByPhoneNumber(request.phoneNumber());
146+
147+
log.info("SMS 인증 성공: phoneNumber={}, isExistingMember={}", request.phoneNumber(), isExistingMember);
148+
return new AuthResDTO.SmsVerifyResponse(verificationToken, isExistingMember);
146149
}
147150

148151
/**
@@ -434,8 +437,8 @@ public AuthResDTO.ReissueResponse reissue(AuthReqDTO.ReissueRequest request) {
434437
String newAccessToken = jwtUtil.createAccessToken(phoneNumber);
435438
String newRefreshToken = jwtUtil.createRefreshToken(phoneNumber);
436439

437-
// 7. RT 업데이트 (Rotation, issuedAt 갱신하여 최대 수명도 연장)
438-
memberToken.updateTokenWithIssuedAt(newRefreshToken, now.plusDays(REFRESH_TOKEN_SLIDING_DAYS), now);
440+
// 7. RT 업데이트 (Rotation, issuedAt 유지하여 최대 수명 30일 보장)
441+
memberToken.updateToken(newRefreshToken, now.plusDays(REFRESH_TOKEN_SLIDING_DAYS));
439442

440443
// 8. lastLoginAt 갱신 (사용자 활동 추적)
441444
Member member = memberToken.getMember();
@@ -449,6 +452,36 @@ public AuthResDTO.ReissueResponse reissue(AuthReqDTO.ReissueRequest request) {
449452
);
450453
}
451454

455+
@Transactional
456+
public void resetPassword(AuthReqDTO.PasswordResetRequest request) {
457+
// 1. Verification Token 검증 및 소멸 (SMS 인증 완료 확인)
458+
validateVerificationToken(request.verificationToken(), request.phoneNumber());
459+
460+
// 2. 회원 조회
461+
Member member = memberRepository.findByPhoneNumber(request.phoneNumber())
462+
.orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND));
463+
464+
// 3. 새 비밀번호 AES 복호화 후 검증
465+
String rawPassword;
466+
try {
467+
rawPassword = new String(hashUtil.decryptAES(request.newPassword()));
468+
} catch (GeneralSecurityException e) {
469+
log.error("AES 복호화 실패: phoneNumber={}", request.phoneNumber(), e);
470+
throw new AuthException(AuthErrorCode.INVALID_PASSWORD);
471+
}
472+
473+
if (!rawPassword.matches("^\\d{6}$")) {
474+
log.warn("간편비밀번호 형식 오류: phoneNumber={}", request.phoneNumber());
475+
throw new AuthException(AuthErrorCode.INVALID_PASSWORD);
476+
}
477+
478+
// 4. 비밀번호 업데이트 및 잠금 해제
479+
member.updateSimplePassword(passwordEncoder.encode(rawPassword));
480+
member.resetLoginFailCount();
481+
482+
log.info("비밀번호 재설정 성공: phoneNumber={}", request.phoneNumber());
483+
}
484+
452485
@Transactional
453486
public void logout(String phoneNumber, String accessToken) {
454487
// 1. RT 삭제
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.example.scoi.domain.auth.service;
22

3-
import com.example.scoi.domain.member.entity.Member;
43
import com.example.scoi.domain.member.repository.MemberRepository;
54
import lombok.RequiredArgsConstructor;
65
import org.springframework.stereotype.Component;
@@ -16,23 +15,23 @@ public class LoginFailCountManager {
1615
/**
1716
* 로그인 실패 카운트를 별도 트랜잭션에서 증가시킵니다.
1817
* REQUIRES_NEW로 외부 트랜잭션 롤백과 무관하게 커밋됩니다.
18+
* @Modifying @Query로 L1 캐시 무관하게 DB 직접 업데이트합니다.
1919
*/
2020
@Transactional(propagation = Propagation.REQUIRES_NEW)
2121
public int increaseFailCount(Long memberId) {
22-
Member member = memberRepository.findById(memberId)
23-
.orElseThrow(() -> new IllegalStateException("Member not found: " + memberId));
24-
member.increaseLoginFailCount();
25-
return member.getLoginFailCount();
22+
memberRepository.incrementLoginFailCount(memberId);
23+
return memberRepository.findById(memberId)
24+
.orElseThrow(() -> new IllegalStateException("Member not found: " + memberId))
25+
.getLoginFailCount();
2626
}
2727

2828
/**
2929
* 로그인 실패 카운트를 별도 트랜잭션에서 초기화합니다.
3030
* SMS 재인증으로 계정 잠금 해제 시 사용 — 이후 비밀번호가 틀려도 잠금 해제는 유지됩니다.
31+
* @Modifying @Query로 L1 캐시 무관하게 DB 직접 업데이트합니다.
3132
*/
3233
@Transactional(propagation = Propagation.REQUIRES_NEW)
3334
public void resetFailCount(Long memberId) {
34-
Member member = memberRepository.findById(memberId)
35-
.orElseThrow(() -> new IllegalStateException("Member not found: " + memberId));
36-
member.resetLoginFailCount();
35+
memberRepository.resetLoginFailCount(memberId);
3736
}
3837
}

src/main/java/com/example/scoi/domain/member/dto/MemberReqDTO.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.example.scoi.domain.member.dto;
22

33
import com.example.scoi.domain.member.enums.ExchangeType;
4+
import jakarta.validation.constraints.Pattern;
45

56
public class MemberReqDTO {
67

@@ -12,7 +13,9 @@ public record ChangePassword(
1213

1314
// 간편 비밀번호 재설정
1415
public record ResetPassword(
15-
String verificationCode,
16+
@Pattern(regexp = "^\\d{11}$")
17+
String phoneNumber,
18+
String verificationToken,
1619
String newPassword
1720
){}
1821

src/main/java/com/example/scoi/domain/member/repository/MemberRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import com.example.scoi.domain.member.entity.Member;
44
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Modifying;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
58

69
import java.util.Optional;
710

@@ -12,4 +15,12 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
1215

1316
// 휴대폰 번호 중복 체크
1417
boolean existsByPhoneNumber(String phoneNumber);
18+
19+
@Modifying
20+
@Query("UPDATE Member m SET m.loginFailCount = m.loginFailCount + 1 WHERE m.id = :memberId")
21+
int incrementLoginFailCount(@Param("memberId") Long memberId);
22+
23+
@Modifying
24+
@Query("UPDATE Member m SET m.loginFailCount = 0 WHERE m.id = :memberId")
25+
int resetLoginFailCount(@Param("memberId") Long memberId);
1526
}

src/main/java/com/example/scoi/domain/member/service/MemberService.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,51 @@ public Optional<Map<String, String>> changePassword(
115115
return Optional.empty();
116116
}
117117

118+
// 간편 비밀번호 재설정
119+
@Transactional
120+
public Void resetPassword(
121+
MemberReqDTO.ResetPassword dto,
122+
String phoneNumber
123+
) {
124+
125+
Member member = memberRepository.findByPhoneNumber(phoneNumber)
126+
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
127+
128+
// 인증된 전화번호인지 확인 (verification:{verificationToken} 키로 조회)
129+
String tokenKey = VERIFICATION_PREFIX + dto.verificationToken();
130+
String verifiedPhoneNumber = redisUtil.get(tokenKey);
131+
if (verifiedPhoneNumber == null || !verifiedPhoneNumber.equals(dto.phoneNumber())) {
132+
throw new MemberException(MemberErrorCode.UNVERIFIED_PHONE_NUMBER);
133+
}
134+
redisUtil.delete(tokenKey);
135+
136+
// 새 간편 비밀번호 검증
137+
String newPassword;
138+
try {
139+
newPassword = new String(hashUtil.decryptAES(dto.newPassword()));
140+
141+
// 6자리 숫자가 아닌 경우
142+
if (!newPassword.matches(SIMPLE_PASSWORD_REGEX)) {
143+
throw new IllegalArgumentException();
144+
}
145+
} catch (GeneralSecurityException e ) {
146+
Map<String, String> binding = new HashMap<>();
147+
binding.put("password", "간편 비밀번호 복호화에 실패했습니다.");
148+
throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding);
149+
} catch (IllegalArgumentException e) {
150+
Map<String, String> binding = new HashMap<>();
151+
binding.put("password", "6자리 숫자만 입력 가능합니다.");
152+
throw new MemberException(GeneralErrorCode.VALIDATION_FAILED, binding);
153+
}
154+
155+
// 간편 비밀번호 변경
156+
member.updateSimplePassword(passwordEncoder.encode(newPassword));
157+
158+
// 로그인 횟수 -> 0
159+
member.resetLoginFailCount();
160+
return null;
161+
}
162+
118163
// 거래소 목록 조회
119164
public List<MemberResDTO.ExchangeList> getExchangeList(
120165
String phoneNumber

0 commit comments

Comments
 (0)