Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] 비밀번호 재설정 기능을 구현한다. #843

Merged
merged 39 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
35a8706
feat: 메일 기본 설정 빈 등록
tkdgur0906 Oct 20, 2024
8f4c7a1
feat: 메일 보내는 MailSender 구현
tkdgur0906 Oct 20, 2024
dea07ab
feat: 비밀번호 찾기 시 검증 이메일 보내는 기능 구현
tkdgur0906 Oct 20, 2024
de52e94
feat: 비밀번호 인증 코드 저장로직 추가
tkdgur0906 Oct 20, 2024
e9c875c
feat: 비밀번호 인증 코드 발송 기능 엔드포인트 변경
tkdgur0906 Oct 20, 2024
8335189
feat: 비밀번호 인증 코드 저장 시 이메일 같이 저장
tkdgur0906 Oct 20, 2024
75e149f
feat: PasswordResetCode 스키마 추가
tkdgur0906 Oct 20, 2024
583bd8e
feat: 입력받은 비밀번호 초기화 코드 맞는지 확인하는 기능 구현
tkdgur0906 Oct 20, 2024
86eb278
feat: 비밀번호 재설정 기능 구현
tkdgur0906 Oct 20, 2024
b0f796c
feat: 잘못된 이메일 형식 에러 처리
tkdgur0906 Oct 21, 2024
632e1e5
feat: 에러 메시지 명확히 수정
tkdgur0906 Oct 21, 2024
fdedae3
test: 비밀번호 초기화 코드 인증 테스트 검증 시간 변경
tkdgur0906 Oct 21, 2024
2071fd2
feat: 메일 기본 설정 빈 등록
tkdgur0906 Oct 20, 2024
7e9f894
feat: 메일 보내는 MailSender 구현
tkdgur0906 Oct 20, 2024
b20e715
feat: 비밀번호 찾기 시 검증 이메일 보내는 기능 구현
tkdgur0906 Oct 20, 2024
dd1b87b
feat: 비밀번호 인증 코드 저장로직 추가
tkdgur0906 Oct 20, 2024
007507c
feat: 비밀번호 인증 코드 발송 기능 엔드포인트 변경
tkdgur0906 Oct 20, 2024
2cb4749
feat: 비밀번호 인증 코드 저장 시 이메일 같이 저장
tkdgur0906 Oct 20, 2024
e8b86a9
feat: PasswordResetCode 스키마 추가
tkdgur0906 Oct 20, 2024
6813f8c
feat: 입력받은 비밀번호 초기화 코드 맞는지 확인하는 기능 구현
tkdgur0906 Oct 20, 2024
bd0688e
feat: 비밀번호 재설정 기능 구현
tkdgur0906 Oct 20, 2024
87ebeba
feat: 잘못된 이메일 형식 에러 처리
tkdgur0906 Oct 21, 2024
58c9b7a
feat: 에러 메시지 명확히 수정
tkdgur0906 Oct 21, 2024
464f311
test: 비밀번호 초기화 코드 인증 테스트 검증 시간 변경
tkdgur0906 Oct 21, 2024
b30334f
Merge branch 'dev-be' into feat/807-find-password
tkdgur0906 Nov 11, 2024
2afdc80
Merge branch 'feat/807-find-password' of https://github.com/woowacour…
tkdgur0906 Nov 11, 2024
f54dfd1
test: 불필요 테스트 코드 삭제
tkdgur0906 Nov 11, 2024
eae3528
feat: 인증 코드 유효기간 5분으로 변경
tkdgur0906 Nov 11, 2024
a105fb6
feat: 비밀번호 초기화 시 인증 코드 삭제
tkdgur0906 Nov 11, 2024
aadbad5
test: 비밀번호 초기화 코드 유효기간 5분으로 변경 반영
tkdgur0906 Nov 11, 2024
f7d9fa9
feat: 비밀번호 재설정 시 로그인 타입 Local인 것 반영
tkdgur0906 Nov 11, 2024
25dbbbd
test: 테스트 displayname 컨벤션 맞게 수정
tkdgur0906 Nov 11, 2024
b42dc5f
refactor: 비밀번호 변경 엔드포인트 수정
tkdgur0906 Nov 11, 2024
89c1ac9
style: 불필요 공백 제거
tkdgur0906 Nov 11, 2024
1dc75bf
feat: dto 이메일 검증 추가
tkdgur0906 Nov 11, 2024
e324e31
refactor: 비밀번호 찾기 기능 서비스 분리
tkdgur0906 Nov 11, 2024
27ddcd7
feat: 비밀번호 초기화 코드가 검증 되었는지 여부 확인하도록 추가
tkdgur0906 Nov 12, 2024
673e6da
feat: 비밀번호 초기화 코드 생성 시 기존 이메일에 대한 코드 모두 삭제
tkdgur0906 Nov 12, 2024
60b521d
feat: 비밀번호 재설정 dto의 인증코드, 새로운 비밀번호 NotBlank 추가
tkdgur0906 Nov 13, 2024
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
1 change: 1 addition & 0 deletions backend/bang-ggood/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies {
implementation 'org.projectlombok:lombok'
implementation 'com.mysql:mysql-connector-j'
implementation 'com.opencsv:opencsv:5.9'
implementation 'org.springframework.boot:spring-boot-starter-mail'

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
import com.bang_ggood.auth.config.AuthRequiredPrincipal;
import com.bang_ggood.auth.controller.cookie.CookieProvider;
import com.bang_ggood.auth.controller.cookie.CookieResolver;
import com.bang_ggood.auth.dto.request.ConfirmPasswordResetCodeRequest;
import com.bang_ggood.auth.dto.request.ForgotPasswordRequest;
import com.bang_ggood.auth.dto.request.LocalLoginRequestV1;
import com.bang_ggood.auth.dto.request.OauthLoginRequest;
import com.bang_ggood.auth.dto.request.RegisterRequestV1;
import com.bang_ggood.auth.dto.request.ResetPasswordRequest;
import com.bang_ggood.auth.dto.response.AuthTokenResponse;
import com.bang_ggood.auth.dto.response.TokenExistResponse;
import com.bang_ggood.auth.service.AuthService;
import com.bang_ggood.auth.service.PasswordResetService;
import com.bang_ggood.user.domain.User;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
Expand All @@ -28,6 +32,7 @@
public class AuthController {

private final AuthService authService;
private final PasswordResetService passwordResetService;
private final CookieProvider cookieProvider;
private final CookieResolver cookieResolver;

Expand Down Expand Up @@ -99,6 +104,25 @@ public ResponseEntity<Void> logout(@AuthRequiredPrincipal User user,
.build();
}

@PostMapping("/v1/password-reset/send-code")
public ResponseEntity<Void> sendPasswordResetEmail(@Valid @RequestBody ForgotPasswordRequest request) {
passwordResetService.sendPasswordResetEmail(request);
return ResponseEntity.noContent().build();
}

@PostMapping("/v1/password-reset/confirm")
public ResponseEntity<Void> confirmPasswordResetCode(@Valid @RequestBody ConfirmPasswordResetCodeRequest request) {
passwordResetService.confirmPasswordResetCode(request);
return ResponseEntity.noContent().build();
}

@PostMapping("/v1/password-reset")
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
passwordResetService.resetPassword(request);
return ResponseEntity.noContent().build();
}


@PostMapping("/accessToken/reissue")
public ResponseEntity<Void> reissueAccessToken(HttpServletRequest httpServletRequest) {
cookieResolver.checkLoginRequired(httpServletRequest);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.bang_ggood.auth.domain;

import com.bang_ggood.BaseEntity;
import com.bang_ggood.user.domain.Email;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Objects;

import static lombok.AccessLevel.PROTECTED;

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class PasswordResetCode extends BaseEntity {
Comment on lines +15 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일회성 데이터 같아서 DB에 저장할 필요는 없다고 생각되는데 혹시 다른 방식 고민해보신게 있나요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 어딘가에 저장되어야하긴 해서 DB에 저장했습니다.
유효시간도 있다보니 레디스에 저장하는게 최선 같은데, 우선은 간단하게 DB에 저장하는 방식으로 구현했습니다!


@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private Email email;

private String code;

private boolean verified;

public PasswordResetCode(String email, String code) {
this.email = new Email(email);
this.code = code;
this.verified = false;
}

public void verify() {
verified = true;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PasswordResetCode that = (PasswordResetCode) o;
return Objects.equals(id, that.id);
}

@Override
public int hashCode() {
return Objects.hashCode(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.bang_ggood.auth.dto.request;

import jakarta.validation.constraints.Email;

public record ConfirmPasswordResetCodeRequest(@Email(message = "유효하지 않은 이메일 형식입니다.") String email,
String code) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.bang_ggood.auth.dto.request;

import jakarta.validation.constraints.Email;

public record ForgotPasswordRequest(@Email(message = "유효하지 않은 이메일 형식입니다.") String email) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.bang_ggood.auth.dto.request;

import jakarta.validation.constraints.Email;

public record ResetPasswordRequest(@Email(message = "유효하지 않은 이메일 형식입니다.") String email,
String code, String newPassword) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.bang_ggood.auth.repository;

import com.bang_ggood.auth.domain.PasswordResetCode;
import com.bang_ggood.global.exception.BangggoodException;
import com.bang_ggood.global.exception.ExceptionCode;
import com.bang_ggood.user.domain.Email;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Optional;

public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, Long> {

boolean existsByEmailAndCodeAndVerifiedTrue(Email email, String code);

@Query("SELECT p FROM PasswordResetCode p " +
"WHERE p.email = :email " +
"AND p.code = :code " +
"AND p.createdAt >= :timeLimit")
Optional<PasswordResetCode> findByEmailAndCodeAndCreatedAtAfter(@Param("email") Email email,
@Param("code") String code,
@Param("timeLimit") LocalDateTime timeLimit);

default PasswordResetCode getByEmailAndCodeAndCreatedAtAfter(@Param("email") Email email,
@Param("code") String code,
@Param("timeLimit") LocalDateTime timeLimit) {
return findByEmailAndCodeAndCreatedAtAfter(email, code, timeLimit)
.orElseThrow(() -> new BangggoodException(ExceptionCode.AUTHENTICATION_PASSWORD_CODE_NOT_FOUND));
}

long countByEmail(Email email);

void deleteByEmailAndCode(Email email, String code);

void deleteByEmail(Email email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
import com.bang_ggood.user.domain.UserType;
import com.bang_ggood.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
Expand All @@ -27,7 +25,6 @@
@Service
public class AuthService {

private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private static final int GUEST_USER_LIMIT = 1;

private final OauthClient oauthClient;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.bang_ggood.auth.service;

import com.bang_ggood.global.exception.BangggoodException;
import com.bang_ggood.global.exception.ExceptionCode;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class MailSender {

private static final String FIND_PASSWORD_MAIL_SUBJECT = "방끗 비밀번호 찾기";

private final JavaMailSender javaMailSender;

public String sendPasswordResetEmail(String email) {
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
try {
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
String code = generatePasswordResetCode();
mimeMessageHelper.setTo(email);
mimeMessageHelper.setSubject(FIND_PASSWORD_MAIL_SUBJECT);
mimeMessageHelper.setText(code);
javaMailSender.send(mimeMessage);
return code;
} catch (MessagingException e) {
throw new BangggoodException(ExceptionCode.MAIL_SEND_ERROR);
}
}

private String generatePasswordResetCode() {
int code = (int) (Math.random() * 1000000);
return String.format("%06d", code);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.bang_ggood.auth.service;

import com.bang_ggood.auth.domain.PasswordResetCode;
import com.bang_ggood.auth.dto.request.ConfirmPasswordResetCodeRequest;
import com.bang_ggood.auth.dto.request.ForgotPasswordRequest;
import com.bang_ggood.auth.dto.request.ResetPasswordRequest;
import com.bang_ggood.auth.repository.PasswordResetCodeRepository;
import com.bang_ggood.global.exception.BangggoodException;
import com.bang_ggood.global.exception.ExceptionCode;
import com.bang_ggood.user.domain.Email;
import com.bang_ggood.user.domain.LoginType;
import com.bang_ggood.user.domain.Password;
import com.bang_ggood.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;

@RequiredArgsConstructor
@Service
public class PasswordResetService {

private static final int PASSWORD_RESET_CODE_EXPIRED_MINUTES = 5;

private final MailSender mailSender;
private final PasswordResetCodeRepository passwordResetCodeRepository;
private final Clock clock;
private final UserRepository userRepository;

public void sendPasswordResetEmail(ForgotPasswordRequest request) {
String code = mailSender.sendPasswordResetEmail(request.email());
passwordResetCodeRepository.deleteByEmail(new Email(request.email()));
passwordResetCodeRepository.save(new PasswordResetCode(request.email(), code));
}

@Transactional
public void confirmPasswordResetCode(ConfirmPasswordResetCodeRequest request) {
LocalDateTime timeLimit = LocalDateTime.now(clock).minusMinutes(PASSWORD_RESET_CODE_EXPIRED_MINUTES);
PasswordResetCode passwordResetCode = passwordResetCodeRepository.getByEmailAndCodeAndCreatedAtAfter(
new Email(request.email()), request.code(), timeLimit);
passwordResetCode.verify();
}

@Transactional
public void resetPassword(ResetPasswordRequest request) {
Email email = new Email(request.email());
String code = request.code();
if (!passwordResetCodeRepository.existsByEmailAndCodeAndVerifiedTrue(email, code)) {
throw new BangggoodException(ExceptionCode.AUTHENTICATION_PASSWORD_CODE_NOT_FOUND);
}

userRepository.updatePasswordByEmail(
new Email(request.email()), new Password(request.newPassword()), LoginType.LOCAL);
passwordResetCodeRepository.deleteByEmailAndCode(email, code);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.bang_ggood.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;

@Configuration
public class MailConfig {

private static final String MAIL_TRANSPORT_PROTOCOL = "mail.transport.protocol";
private static final String MAIL_SMTP_AUTH = "mail.smtp.auth";
private static final String MAIL_SMTP_STARTTLS_ENABLE = "mail.smtp.starttls.enable";
private static final String MAIL_DEBUG = "mail.debug";

@Value("${spring.mail.host}")
private String host;

@Value("${spring.mail.username}")
private String username;

@Value("${spring.mail.password}")
private String password;

@Value("${spring.mail.port}")
private int port;

@Value("${spring.mail.properties.mail.transport.protocol}")
private String protocol;

@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;

@Value("${spring.mail.properties.mail.starttls.enable}")
private boolean enable;

@Value("${spring.mail.properties.mail.smtp.debug}")
private boolean debug;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공백 👀

@Bean
public JavaMailSender getJavaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);

Properties props = mailSender.getJavaMailProperties();
props.put(MAIL_TRANSPORT_PROTOCOL, protocol);
props.put(MAIL_SMTP_AUTH, auth);
props.put(MAIL_SMTP_STARTTLS_ENABLE, enable);
props.put(MAIL_DEBUG, debug);

return mailSender;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.bang_ggood.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;

@Configuration
public class TimeConfig {

@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public enum ClientExceptionCode {
AUTH_ACCESS_TOKEN_EMPTY,
AUTH_TOKEN_EMPTY,
AUTH_TOKEN_INVALID,
AUTH_PASSWORD_CODE_NOT_FOUND,
CHECKLIST_ERROR,
CHECKLIST_NOT_FOUND,
CHECKLIST_SERVER_ERROR,
Expand All @@ -21,6 +22,7 @@ public enum ClientExceptionCode {
USER_NOT_FOUND,
LOGIN_ERROR,
INVALID_PARAMETER,
MAIL_SEND_ERROR,

// TODO: 임의 사용 지워질 코드
AUTH_TOKEN_NOT_OWNED_BY_USER,
Expand Down
Loading
Loading