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

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
Open
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
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,9 +3,12 @@
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;
Expand Down Expand Up @@ -86,6 +89,25 @@ public ResponseEntity<Void> logout(@AuthRequiredPrincipal User user,
.build();
}

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

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

@PostMapping("/v1/password-reset/new-password")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
@PostMapping("/v1/password-reset/new-password")
@PostMapping("/v1/password-reset")

마지막은 new-password 단어를 빼도 의미전달이 될 것 같은데 어떠신가용

public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
authService.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,49 @@
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에 저장할 필요는 없다고 생각되는데 혹시 다른 방식 고민해보신게 있나요??


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

private Email email;

private String code;

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

@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,4 @@
package com.bang_ggood.auth.dto.request;

public record ConfirmPasswordResetCodeRequest(String email, String code) {
}
Comment on lines +3 to +4
Copy link
Contributor

Choose a reason for hiding this comment

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

요기는 Valid 필요없나요??

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,4 @@
package com.bang_ggood.auth.dto.request;

public record ResetPasswordRequest(String email, String code, String newPassword) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.bang_ggood.auth.repository;

import com.bang_ggood.auth.domain.PasswordResetCode;
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;

public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, Long> {

boolean existsByEmailAndCode(Email email, String code);

@Query("SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END " +
"FROM PasswordResetCode p " +
"WHERE p.email = :email " +
"AND p.code = :code " +
"AND p.createdAt >= :timeLimit")
boolean existsByEmailAndCodeAndCreatedAtAfter(@Param("email") Email email,
@Param("code") String code,
@Param("timeLimit") LocalDateTime timeLimit);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
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.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.OauthInfoApiResponse;
import com.bang_ggood.auth.repository.PasswordResetCodeRepository;
import com.bang_ggood.auth.service.jwt.JwtTokenProvider;
import com.bang_ggood.auth.service.jwt.JwtTokenResolver;
import com.bang_ggood.auth.service.oauth.OauthClient;
Expand All @@ -13,6 +18,7 @@
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.domain.User;
import com.bang_ggood.user.domain.UserType;
import com.bang_ggood.user.repository.UserRepository;
Expand All @@ -22,6 +28,8 @@
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;

@RequiredArgsConstructor
Expand All @@ -30,12 +38,16 @@ public class AuthService {

private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private static final int GUEST_USER_LIMIT = 1;
private static final int PASSWORD_RESET_CODE_EXPIRED_MINUTES = 3;
Copy link
Contributor

Choose a reason for hiding this comment

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

5분 혹은 10분이 적당하지 않을까요?!


private final OauthClient oauthClient;
private final JwtTokenProvider jwtTokenProvider;
private final JwtTokenResolver jwtTokenResolver;
private final DefaultChecklistService defaultChecklistService;
private final UserRepository userRepository;
private final MailSender mailSender;
private final PasswordResetCodeRepository passwordResetCodeRepository;
private final Clock clock;
Comment on lines +48 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

비밀번호 찾기에 대한 코드량이 상당한 것 같은데 새로운 서비스로 분리해보는 건 어떨까요? 해당 클래스들이 의존성을 가질 수 있겠네요!


@Transactional
public Long register(RegisterRequestV1 request) {
Expand Down Expand Up @@ -109,6 +121,31 @@ public void logout(String accessToken, String refreshToken, User user) {
validateTokenOwnership(user, accessAuthUser, refreshAuthUser);
}

public void sendPasswordResetEmail(ForgotPasswordRequest request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@transactional 빠진 것 같아요 👀

String code = mailSender.sendPasswordResetEmail(request.email());
passwordResetCodeRepository.save(new PasswordResetCode(request.email(), code));
}

@Transactional(readOnly = true)
public void confirmPasswordResetCode(ConfirmPasswordResetCodeRequest request) {
LocalDateTime timeLimit = LocalDateTime.now(clock).minusMinutes(PASSWORD_RESET_CODE_EXPIRED_MINUTES);
boolean isValid = passwordResetCodeRepository.existsByEmailAndCodeAndCreatedAtAfter(
new Email(request.email()), request.code(), timeLimit);
if (!isValid) {
throw new BangggoodException(ExceptionCode.AUTHENTICATION_PASSWORD_CODE_NOT_FOUND);
}
}

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

userRepository.updatePasswordByEmail(
new Email(request.email()), new Password(request.newPassword()));
Copy link
Contributor

Choose a reason for hiding this comment

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

여기서 패스워드 리셋 코드 저장한 걸 삭제하는 건 어떨까요?!

}

@Transactional(readOnly = true)
public User getAuthUser(String token) {
AuthUser authUser = jwtTokenResolver.resolveAccessToken(token);
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,59 @@
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
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public enum ExceptionCode {
AUTHENTICATION_TOKEN_NOT_OWNED_BY_USER(HttpStatus.UNAUTHORIZED, ClientExceptionCode.AUTH_TOKEN_NOT_OWNED_BY_USER, "해당 유저의 토큰이 아닙니다."),
AUTHENTICATION_TOKEN_USER_MISMATCH(HttpStatus.UNAUTHORIZED, ClientExceptionCode.AUTH_TOKEN_USER_MISMATCH, "엑세스 토큰과 리프레시 토큰의 소유자가 다릅니다."),
AUTHENTICATION_TOKEN_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, ClientExceptionCode.AUTH_TOKEN_INVALID, "토큰 타입이 올바르지 않습니다."),
AUTHENTICATION_PASSWORD_CODE_NOT_FOUND(HttpStatus.BAD_REQUEST, ClientExceptionCode.AUTH_PASSWORD_CODE_NOT_FOUND, "비밀번호 재설정 인증 코드가 일치하지 않습니다."),
OAUTH_TOKEN_INTERNAL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, ClientExceptionCode.OAUTH_SERVER_ERROR, "카카오 서버와 통신하는 과정 중 예상치 못한 예외가 발생했습니다."),
OAUTH_REDIRECT_URI_MISMATCH(HttpStatus.BAD_REQUEST, ClientExceptionCode.OAUTH_SERVER_ERROR, "일치하는 Redirect URI가 존재하지 않습니다."),

Expand All @@ -84,7 +85,10 @@ public enum ExceptionCode {

// Station
STATION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, ClientExceptionCode.STATION_SERVER_ERROR, "지하철 역을 찾을 수 없습니다."),
STATION_NAME_NOT_SAME(HttpStatus.INTERNAL_SERVER_ERROR, ClientExceptionCode.STATION_SERVER_ERROR, "지하철 역을 찾을 수 없습니다.");
STATION_NAME_NOT_SAME(HttpStatus.INTERNAL_SERVER_ERROR, ClientExceptionCode.STATION_SERVER_ERROR, "지하철 역을 찾을 수 없습니다."),

//Mail
MAIL_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ClientExceptionCode.MAIL_SEND_ERROR, "메일 전송 중 오류가 발생했습니다.");

private final HttpStatus httpStatus;
private final ClientExceptionCode clientExceptionCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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.domain.User;
import com.bang_ggood.user.domain.UserType;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -29,6 +30,11 @@ default User getUserById(Long id) {
@Query("SELECT u FROM User u WHERE u.email = :email and u.loginType = :loginType and u.deleted = false")
Optional<User> findByEmailAndLoginType(@Param("email") Email email, @Param("loginType") LoginType loginType);

@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE User u SET u.password = :newPassword WHERE u.email = :email AND u.deleted = false")
void updatePasswordByEmail(@Param("email") Email email, @Param("newPassword") Password newPassword);

Comment on lines +36 to +37
Copy link
Contributor

Choose a reason for hiding this comment

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

로그인 타입도 지정해 줘야 될 것 같아요!

@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE User u SET u.deleted = true WHERE u.id = :id")
Expand Down
Loading
Loading