Skip to content

Commit 44ccc20

Browse files
authored
Merge pull request #14 from FlipNoteTeam/feat/password-reset [FN-65]
Feat: [FN-65] 비밀번호 재설정 링크 전송
2 parents 5e1654c + ec1dbc0 commit 44ccc20

21 files changed

Lines changed: 425 additions & 89 deletions

src/main/java/project/flipnote/auth/constants/AuthRedisKey.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public enum AuthRedisKey implements RedisKeys {
1111
EMAIL_VERIFIED("auth:email:verified:%s", 600),
1212
TOKEN_VERSION("auth:token:version:%d", 3600),
1313
TOKEN_BLACKLIST("auth:token:blacklist:%s", -1),
14+
PASSWORD_RESET_TOKEN("auth:password_reset:token:%s", PasswordResetConstants.TOKEN_TTL_MINUTES * 60),
15+
PASSWORD_RESET_EMAIL("auth:password_reset:email:%s", PasswordResetConstants.TOKEN_TTL_MINUTES * 60),
1416
;
1517

1618
private final String pattern;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package project.flipnote.auth.constants;
2+
3+
import lombok.AccessLevel;
4+
import lombok.NoArgsConstructor;
5+
6+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
7+
public final class PasswordResetConstants {
8+
public static final int TOKEN_TTL_MINUTES = 30;
9+
}

src/main/java/project/flipnote/auth/controller/AuthController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import lombok.RequiredArgsConstructor;
1414
import project.flipnote.auth.model.EmailVerificationConfirmRequest;
1515
import project.flipnote.auth.model.EmailVerificationRequest;
16+
import project.flipnote.auth.model.PasswordResetCreateRequest;
1617
import project.flipnote.auth.model.TokenPair;
1718
import project.flipnote.auth.model.UserLoginRequest;
1819
import project.flipnote.auth.model.UserLoginResponse;
@@ -89,4 +90,13 @@ public ResponseEntity<UserLoginResponse> refreshToken(
8990
.header(HttpHeaders.SET_COOKIE, cookie.toString())
9091
.body(UserLoginResponse.from(tokenPair.accessToken()));
9192
}
93+
94+
@PostMapping("/password-resets")
95+
public ResponseEntity<Void> requestPasswordReset(
96+
@Valid @RequestBody PasswordResetCreateRequest req
97+
) {
98+
authService.requestPasswordReset(req);
99+
100+
return ResponseEntity.noContent().build();
101+
}
92102
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package project.flipnote.auth.event;
2+
3+
public record PasswordResetCreateEvent(
4+
String to,
5+
String link
6+
) {
7+
}

src/main/java/project/flipnote/auth/exception/AuthErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ public enum AuthErrorCode implements ErrorCode {
1616
INVALID_VERIFICATION_CODE(HttpStatus.FORBIDDEN, "AUTH_004", "잘못된 인증번호입니다. 입력한 인증번호를 확인해 주세요."),
1717
EXISTING_EMAIL(HttpStatus.CONFLICT, "AUTH_005", "이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요."),
1818
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_006", "인증 정보가 유효하지 않습니다."),
19-
UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "AUTH_007", "인증되지 않은 이메일입니다. 이메일 인증을 완료해 주세요.");
19+
UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "AUTH_007", "인증되지 않은 이메일입니다. 이메일 인증을 완료해 주세요."),
20+
ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.CONFLICT, "AUTH_008", "이미 유효한 비밀번호 재설정 링크가 존재합니다. 이메일을 확인해주세요.");
2021

2122
private final HttpStatus httpStatus;
2223
private final String code;

src/main/java/project/flipnote/auth/listener/EmailVerificationEventListener.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ public void handleEmailVerificationSendEvent(EmailVerificationSendEvent event) {
3434

3535
@Recover
3636
public void recover(EmailSendException ex, EmailVerificationSendEvent event) {
37-
log.error("이메일 인증번호 전송 3회 실패: to={}", event.to(), ex);
37+
log.error("이메일 인증번호 전송 실패: to={}", event.to(), ex);
3838
}
3939
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package project.flipnote.auth.listener;
2+
3+
import org.springframework.context.event.EventListener;
4+
import org.springframework.retry.annotation.Backoff;
5+
import org.springframework.retry.annotation.Recover;
6+
import org.springframework.retry.annotation.Retryable;
7+
import org.springframework.scheduling.annotation.Async;
8+
import org.springframework.stereotype.Component;
9+
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import project.flipnote.auth.constants.PasswordResetConstants;
13+
import project.flipnote.auth.event.PasswordResetCreateEvent;
14+
import project.flipnote.common.exception.EmailSendException;
15+
import project.flipnote.infra.email.EmailService;
16+
17+
@Slf4j
18+
@RequiredArgsConstructor
19+
@Component
20+
public class PasswordResetCreateEventListener {
21+
22+
private final EmailService emailService;
23+
24+
@Async
25+
@Retryable(
26+
maxAttempts = 3,
27+
retryFor = { EmailSendException.class },
28+
backoff = @Backoff(delay = 2000, multiplier = 2)
29+
)
30+
@EventListener
31+
public void handlePasswordResetCreateEvent(PasswordResetCreateEvent event) {
32+
emailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES);
33+
}
34+
35+
@Recover
36+
public void recover(EmailSendException ex, PasswordResetCreateEvent event) {
37+
log.error("비밀번호 재설정 링크 전송 실패: to={}", event.to(), ex);
38+
}
39+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package project.flipnote.auth.model;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
6+
public record PasswordResetCreateRequest(
7+
@Email @NotBlank
8+
String email
9+
) {
10+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package project.flipnote.auth.repository;
2+
3+
import java.time.Duration;
4+
import java.util.List;
5+
import java.util.Optional;
6+
7+
import org.springframework.dao.DataAccessException;
8+
import org.springframework.data.redis.core.RedisOperations;
9+
import org.springframework.data.redis.core.SessionCallback;
10+
import org.springframework.data.redis.core.StringRedisTemplate;
11+
import org.springframework.stereotype.Repository;
12+
13+
import lombok.RequiredArgsConstructor;
14+
import project.flipnote.auth.constants.AuthRedisKey;
15+
16+
@RequiredArgsConstructor
17+
@Repository
18+
public class PasswordResetRedisRepository {
19+
20+
private final StringRedisTemplate stringRedisTemplate;
21+
22+
public void saveToken(String email, String token) {
23+
stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
24+
@Override
25+
public List<Object> execute(RedisOperations operations) throws DataAccessException {
26+
operations.multi();
27+
28+
String tokenKey = AuthRedisKey.PASSWORD_RESET_TOKEN.key(token);
29+
Duration tokenTtl = AuthRedisKey.PASSWORD_RESET_TOKEN.getTtl();
30+
operations.opsForValue().set(tokenKey, email, tokenTtl);
31+
32+
String emailKey = AuthRedisKey.PASSWORD_RESET_EMAIL.key(email);
33+
Duration emailTtl = AuthRedisKey.PASSWORD_RESET_EMAIL.getTtl();
34+
operations.opsForValue().set(emailKey, "1", emailTtl);
35+
36+
return operations.exec();
37+
}
38+
});
39+
}
40+
41+
public Optional<String> findEmailByToken(String token) {
42+
String key = AuthRedisKey.PASSWORD_RESET_TOKEN.key(token);
43+
44+
String email = stringRedisTemplate.opsForValue().get(key);
45+
46+
return Optional.ofNullable(email);
47+
}
48+
49+
public void deleteToken(String email, String token) {
50+
String tokenKey = AuthRedisKey.PASSWORD_RESET_TOKEN.key(token);
51+
stringRedisTemplate.delete(tokenKey);
52+
53+
String emailKey = AuthRedisKey.PASSWORD_RESET_EMAIL.key(email);
54+
stringRedisTemplate.delete(emailKey);
55+
}
56+
57+
public boolean hasActiveToken(String email) {
58+
String key = AuthRedisKey.PASSWORD_RESET_EMAIL.key(email);
59+
60+
return stringRedisTemplate.hasKey(key);
61+
}
62+
}

src/main/java/project/flipnote/auth/service/AuthService.java

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package project.flipnote.auth.service;
22

3-
import java.security.SecureRandom;
43
import java.util.Objects;
54

65
import org.springframework.context.ApplicationEventPublisher;
@@ -11,13 +10,19 @@
1110
import lombok.extern.slf4j.Slf4j;
1211
import project.flipnote.auth.constants.VerificationConstants;
1312
import project.flipnote.auth.event.EmailVerificationSendEvent;
13+
import project.flipnote.auth.event.PasswordResetCreateEvent;
1414
import project.flipnote.auth.exception.AuthErrorCode;
1515
import project.flipnote.auth.model.EmailVerificationConfirmRequest;
1616
import project.flipnote.auth.model.EmailVerificationRequest;
17+
import project.flipnote.auth.model.PasswordResetCreateRequest;
1718
import project.flipnote.auth.model.TokenPair;
1819
import project.flipnote.auth.model.UserLoginRequest;
1920
import project.flipnote.auth.repository.EmailVerificationRedisRepository;
21+
import project.flipnote.auth.repository.PasswordResetRedisRepository;
2022
import project.flipnote.auth.repository.TokenBlacklistRedisRepository;
23+
import project.flipnote.auth.util.PasswordResetTokenGenerator;
24+
import project.flipnote.auth.util.VerificationCodeGenerator;
25+
import project.flipnote.common.config.ClientProperties;
2126
import project.flipnote.common.exception.BizException;
2227
import project.flipnote.common.security.dto.UserAuth;
2328
import project.flipnote.common.security.jwt.JwtComponent;
@@ -34,11 +39,12 @@ public class AuthService {
3439
private final JwtComponent jwtComponent;
3540
private final EmailVerificationRedisRepository emailVerificationRedisRepository;
3641
private final TokenBlacklistRedisRepository tokenBlacklistRedisRepository;
37-
3842
private final PasswordEncoder passwordEncoder;
3943
private final ApplicationEventPublisher eventPublisher;
40-
41-
private static final SecureRandom random = new SecureRandom();
44+
private final VerificationCodeGenerator verificationCodeGenerator;
45+
private final PasswordResetTokenGenerator passwordResetTokenGenerator;
46+
private final PasswordResetRedisRepository passwordResetRedisRepository;
47+
private final ClientProperties clientProperties;
4248

4349
public TokenPair login(UserLoginRequest req) {
4450
User user = findActiveUserByEmail(req.email());
@@ -49,12 +55,12 @@ public TokenPair login(UserLoginRequest req) {
4955
}
5056

5157
public void sendEmailVerificationCode(EmailVerificationRequest req) {
52-
final String email = req.email();
58+
String email = req.email();
5359

5460
validateEmailIsAvailable(email);
5561
validateVerificationCodeNotExists(email);
5662

57-
final String code = generateVerificationCode(VerificationConstants.CODE_LENGTH);
63+
String code = verificationCodeGenerator.generateVerificationCode(VerificationConstants.CODE_LENGTH);
5864

5965
emailVerificationRedisRepository.saveCode(email, code);
6066

@@ -85,18 +91,28 @@ public TokenPair refreshToken(String refreshToken) {
8591
return jwtComponent.generateTokenPair(userAuth);
8692
}
8793

94+
public void requestPasswordReset(PasswordResetCreateRequest req) {
95+
String email = req.email();
96+
if (passwordResetRedisRepository.hasActiveToken(email)) {
97+
throw new BizException(AuthErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK);
98+
}
99+
100+
boolean existUser = userRepository.existsByEmailAndStatus(email, UserStatus.ACTIVE);
101+
if (existUser) {
102+
String token = passwordResetTokenGenerator.generateToken();
103+
passwordResetRedisRepository.saveToken(email, token);
104+
105+
String link = clientProperties.buildPasswordResetUrl(token);
106+
eventPublisher.publishEvent(new PasswordResetCreateEvent(email, link));
107+
}
108+
}
109+
88110
public void validatePasswordMatch(String rawPassword, String encodedPassword) {
89111
if (!passwordEncoder.matches(rawPassword, encodedPassword)) {
90112
throw new BizException(AuthErrorCode.INVALID_CREDENTIALS);
91113
}
92114
}
93115

94-
private String generateVerificationCode(int length) {
95-
int origin = (int)Math.pow(10, length - 1);
96-
int bound = (int)Math.pow(10, length);
97-
return String.valueOf(random.nextInt(origin, bound));
98-
}
99-
100116
private User findActiveUserByEmail(String email) {
101117
return userRepository.findByEmailAndStatus(email, UserStatus.ACTIVE)
102118
.orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS));

0 commit comments

Comments
 (0)