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

feat: 비밀번호 변경 로직 구현 #342

Merged
merged 16 commits into from
Apr 8, 2024
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 @@ -5,11 +5,10 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import in.koreatech.koin.domain.coop.dto.DiningImageRequest;
import org.springframework.web.bind.annotation.RequestMapping;

import in.koreatech.koin.domain.coop.dto.SoldOutRequest;
import in.koreatech.koin.domain.coop.service.CoopService;
import in.koreatech.koin.global.auth.Auth;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import in.koreatech.koin.domain.user.dto.AuthResponse;
import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest;
import in.koreatech.koin.domain.user.dto.FindPasswordRequest;
import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest;
import in.koreatech.koin.domain.user.dto.StudentRegisterRequest;
import in.koreatech.koin.domain.user.dto.StudentResponse;
Expand Down Expand Up @@ -187,4 +188,20 @@ ResponseEntity<Void> checkDuplicationOfNickname(
ResponseEntity<AuthResponse> getAuth(
@Auth(permit = {STUDENT, OWNER, COOP}) Long userId
);

@ApiResponses(
value = {
@ApiResponse(responseCode = "201"),
@ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true)))
}
)
@Operation(summary = "비밀번호 초기(변경) 메일 발송")
@PostMapping("/user/find/password")
ResponseEntity<Void> findPassword(
@RequestBody @Valid FindPasswordRequest findPasswordRequest,
@ServerURL String serverURL
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,37 @@

import java.net.URI;

import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;

import in.koreatech.koin.domain.user.dto.AuthTokenRequest;
import in.koreatech.koin.domain.user.dto.AuthResponse;
import in.koreatech.koin.domain.user.dto.AuthTokenRequest;
import in.koreatech.koin.domain.user.dto.EmailCheckExistsRequest;
import in.koreatech.koin.domain.user.dto.FindPasswordRequest;
import in.koreatech.koin.domain.user.dto.NicknameCheckExistsRequest;
import in.koreatech.koin.domain.user.dto.StudentRegisterRequest;
import in.koreatech.koin.domain.user.dto.StudentResponse;
import in.koreatech.koin.domain.user.dto.StudentUpdateRequest;
import in.koreatech.koin.domain.user.dto.StudentUpdateResponse;
import in.koreatech.koin.domain.user.dto.UserLoginRequest;
import in.koreatech.koin.domain.user.dto.UserLoginResponse;
import in.koreatech.koin.domain.user.dto.UserPasswordChangeRequest;
import in.koreatech.koin.domain.user.dto.UserTokenRefreshRequest;
import in.koreatech.koin.domain.user.dto.UserTokenRefreshResponse;

import in.koreatech.koin.domain.user.service.StudentService;
import in.koreatech.koin.domain.user.service.UserService;
import in.koreatech.koin.global.auth.Auth;
import in.koreatech.koin.global.host.ServerURL;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

Expand Down Expand Up @@ -105,7 +109,8 @@ public ResponseEntity<Void> checkUserEmailExist(
@PostMapping("/user/student/register")
public ResponseEntity<Void> studentRegister(
@Valid @RequestBody StudentRegisterRequest request,
@ServerURL String serverURL) {
@ServerURL String serverURL
) {
studentService.studentRegister(request, serverURL);
return ResponseEntity.ok().build();
}
Expand Down Expand Up @@ -134,4 +139,31 @@ public ResponseEntity<AuthResponse> getAuth(
AuthResponse authResponse = userService.getAuth(userId);
return ResponseEntity.ok().body(authResponse);
}

@PostMapping("/user/find/password")
public ResponseEntity<Void> findPassword(
@RequestBody @Valid FindPasswordRequest request,
@ServerURL String serverURL
) {
studentService.findPassword(request, serverURL);
return new ResponseEntity<>(HttpStatusCode.valueOf(201));
}

@GetMapping("/user/change/password/config")
public ModelAndView checkResetToken(
@ServerURL String serverUrl,
@RequestParam("reset_token") String resetToken
) {
return studentService.checkResetToken(resetToken, serverUrl);
}

@Hidden
@PostMapping("/user/change/password/submit")
public ResponseEntity<Void> changePassword(
@RequestBody UserPasswordChangeRequest request,
@RequestParam("reset_token") String resetToken
) {
studentService.changePassword(request, resetToken);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package in.koreatech.koin.domain.user.dto;

import static com.fasterxml.jackson.databind.PropertyNamingStrategies.*;
import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;

import com.fasterxml.jackson.databind.annotation.JsonNaming;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package in.koreatech.koin.domain.user.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import com.fasterxml.jackson.annotation.JsonProperty;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;

public record FindPasswordRequest(
@Email(message = "아우누리 계정 형식이 아닙니다. ${validatedValue}")
@NotNull(message = "이메일은 비어있을 수 없습니다.")
@Schema(description = "이메일 주소", requiredMode = REQUIRED, example = "asdf@koreatech.ac.kr")
@JsonProperty(value = "address")
String email
) {

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package in.koreatech.koin.domain.user.dto;

import static com.fasterxml.jackson.databind.PropertyNamingStrategies.*;
import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;

import java.time.Clock;
import java.time.LocalDateTime;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package in.koreatech.koin.domain.user.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

Copy link
Contributor

Choose a reason for hiding this comment

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

C

개행 한 줄 지워주세요!!

Copy link
Member Author

Choose a reason for hiding this comment

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

@JsonNaming(SnakeCaseStrategy.class)
public record UserPasswordChangeRequest(
String password
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package in.koreatech.koin.domain.user.exception;

import in.koreatech.koin.global.auth.exception.AuthenticationException;

public class UserResetTokenExpiredException extends AuthenticationException {

private static final String DEFAULT_MESSAGE = "비밀번호 재설정 토큰이 만료되었습니다.";

public UserResetTokenExpiredException(String message) {
super(message);
}

public static UserResetTokenExpiredException withDetail(String detail) {
String message = String.format("%s %s", DEFAULT_MESSAGE, detail);
return new UserResetTokenExpiredException(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public ModelAndView toModelAndViewForStudent() {
if (user.getAuthExpiredAt().isBefore(LocalDateTime.now(clock))) {
return createErrorModelAndView("이미 만료된 토큰입니다.");
}
if (!user.getIsAuthed()) {
if (!user.isAuthed()) {
user.auth();
eventPublisher.publishEvent(new StudentRegisterEvent(user.getEmail()));
return createSuccessModelAndView();
Expand Down
27 changes: 21 additions & 6 deletions src/main/java/in/koreatech/koin/domain/user/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import static lombok.AccessLevel.PROTECTED;

import java.time.Clock;
import java.time.LocalDateTime;

import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import org.springframework.security.crypto.password.PasswordEncoder;

import in.koreatech.koin.domain.user.exception.UserResetTokenExpiredException;
import in.koreatech.koin.global.config.LocalDateTimeAttributeConverter;
import in.koreatech.koin.global.domain.BaseEntity;
import jakarta.persistence.Column;
Expand All @@ -33,6 +35,7 @@
@SQLDelete(sql = "UPDATE users SET is_deleted = true WHERE id = ?")
@NoArgsConstructor(access = PROTECTED)
public class User extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -70,7 +73,7 @@ public class User extends BaseEntity {

@NotNull
@Column(name = "is_authed", nullable = false)
private Boolean isAuthed = false;
private boolean isAuthed = false;

@Column(name = "last_logged_at")
private LocalDateTime lastLoggedAt;
Expand All @@ -87,26 +90,27 @@ public class User extends BaseEntity {
@Column(name = "auth_token")
private String authToken;

@Column(name = "auth_expired_at")
@Convert(converter = LocalDateTimeAttributeConverter.class)
@Column(name = "auth_expired_at")
private LocalDateTime authExpiredAt;

@Size(max = 255)
@Column(name = "reset_token")
private String resetToken;

@Column(name = "reset_expired_at")
@Convert(converter = LocalDateTimeAttributeConverter.class)
@Column(name = "reset_expired_at")
private LocalDateTime resetExpiredAt;

@Column(name = "device_token", nullable = true)
private String deviceToken;

@Builder
private User(String password, String nickname, String name, String phoneNumber, UserType userType,
String email, UserGender gender, Boolean isAuthed, LocalDateTime lastLoggedAt, String profileImageUrl,
Boolean isDeleted, String authToken, LocalDateTime authExpiredAt, String resetToken, LocalDateTime resetExpiredAt,
String deviceToken) {
String email, UserGender gender, boolean isAuthed, LocalDateTime lastLoggedAt, String profileImageUrl,
Boolean isDeleted, String authToken, LocalDateTime authExpiredAt, String resetToken,
LocalDateTime resetExpiredAt,
String deviceToken) {
this.password = password;
this.nickname = nickname;
this.name = name;
Expand Down Expand Up @@ -145,6 +149,11 @@ public void updatePassword(PasswordEncoder passwordEncoder, String password) {
this.password = passwordEncoder.encode(password);
}

public void generateResetTokenForFindPassword(Clock clock) {
Copy link
Contributor

Choose a reason for hiding this comment

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

A

Clock을 사용하는 것은 처음보는데, 꽤나 유용하네요 :O

Copy link
Member Author

Choose a reason for hiding this comment

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

코드리뷰 많이 하고다니시죠

Copy link
Contributor

Choose a reason for hiding this comment

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

죄송합니다 . . . . 분발하겠습니다

this.resetExpiredAt = LocalDateTime.now(clock).plusHours(1);
this.resetToken = this.email + this.resetExpiredAt;
}

public void update(String nickname, String name, String phoneNumber, UserGender gender) {
this.nickname = nickname;
this.name = name;
Expand All @@ -155,4 +164,10 @@ public void update(String nickname, String name, String phoneNumber, UserGender
public void auth() {
this.isAuthed = true;
}

public void validateResetToken() {
if (resetExpiredAt.isBefore(LocalDateTime.now())) {
throw UserResetTokenExpiredException.withDetail("resetToken: " + resetToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public interface UserRepository extends Repository<User, Long> {

Optional<User> findByAuthToken(String authToken);

Optional<User> findAllByResetToken(String resetToken);

default User getByEmail(String email) {
return findByEmail(email)
.orElseThrow(() -> UserNotFoundException.withDetail("email: " + email));
Expand All @@ -34,6 +36,11 @@ default User getByNickname(String nickname) {
.orElseThrow(() -> UserNotFoundException.withDetail("nickname: " + nickname));
}

default User getByResetToken(String resetToken) {
return findAllByResetToken(resetToken)
.orElseThrow(() -> UserNotFoundException.withDetail("resetToken: " + resetToken));
}

boolean existsByNickname(String nickname);

void delete(User user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
import org.springframework.web.servlet.ModelAndView;

import in.koreatech.koin.domain.user.dto.AuthTokenRequest;
import in.koreatech.koin.domain.user.dto.FindPasswordRequest;
import in.koreatech.koin.domain.user.dto.StudentRegisterRequest;
import in.koreatech.koin.domain.user.dto.StudentResponse;
import in.koreatech.koin.domain.user.dto.StudentUpdateRequest;
import in.koreatech.koin.domain.user.dto.StudentUpdateResponse;
import in.koreatech.koin.domain.user.dto.UserPasswordChangeRequest;
import in.koreatech.koin.domain.user.exception.DuplicationNicknameException;
import in.koreatech.koin.domain.user.exception.StudentDepartmentNotValidException;
import in.koreatech.koin.domain.user.exception.StudentNumberNotValidException;
Expand All @@ -27,6 +29,7 @@
import in.koreatech.koin.domain.user.repository.StudentRepository;
import in.koreatech.koin.domain.user.repository.UserRepository;
import in.koreatech.koin.global.domain.email.exception.DuplicationEmailException;
import in.koreatech.koin.global.domain.email.form.StudentPasswordChangeData;
import in.koreatech.koin.global.domain.email.form.StudentRegistrationData;
import in.koreatech.koin.global.domain.email.model.EmailAddress;
import in.koreatech.koin.global.domain.email.service.MailService;
Expand Down Expand Up @@ -121,10 +124,33 @@ private void validateStudentNumber(String studentNumber) {
if (studentNumber == null) {
return;
}
Integer studentNumberYear = Student.parseStudentNumberYear(studentNumber);
int studentNumberYear = Student.parseStudentNumberYear(studentNumber);
if (studentNumberYear < 1992
|| new LocalDateTime().now().getYear() < studentNumberYear) {
|| LocalDateTime.now().getYear() < studentNumberYear) {
throw StudentNumberNotValidException.withDetail("studentNumber: " + studentNumber);
}
}

@Transactional
public void findPassword(FindPasswordRequest request, String serverURL) {
User user = userRepository.getByEmail(request.email());
user.generateResetTokenForFindPassword(clock);
User authedUser = userRepository.save(user);
mailService.sendMail(request.email(), new StudentPasswordChangeData(serverURL, authedUser.getResetToken()));
}

public ModelAndView checkResetToken(String resetToken, String serverUrl) {
Copy link
Contributor

Choose a reason for hiding this comment

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

A

ModelAndView를 사용 방법을 잘 익혀야겠습니다.
@Controller 사용할 때 처럼 하니 @RestController에서는 정적 페이지가 깨져버리는..😢

ModelAndView modelAndView = new ModelAndView("change_password_config");
modelAndView.addObject("contextPath", serverUrl);
modelAndView.addObject("resetToken", resetToken);
return modelAndView;
}

@Transactional
public void changePassword(UserPasswordChangeRequest request, String resetToken) {
User authedUser = userRepository.getByResetToken(resetToken);
authedUser.validateResetToken();
authedUser.updatePassword(passwordEncoder, request.password());
userRepository.save(authedUser);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ public class LocalDateTimeAttributeConverter implements AttributeConverter<Local

@Override
public String convertToDatabaseColumn(LocalDateTime localDateTime) {
if (localDateTime == null)
if (localDateTime == null) {
return null;
}
return localDateTime.format(formatter);
}

@Override
public LocalDateTime convertToEntityAttribute(String dbData) {
if (dbData == null)
if (dbData == null) {
return null;
}
return LocalDateTime.parse(dbData, formatter);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package in.koreatech.koin.global.config;


import java.util.List;

import org.springframework.context.annotation.Configuration;
Expand Down
Loading
Loading