-
Notifications
You must be signed in to change notification settings - Fork 44
feat: User API 구현 (회원가입, 내 정보 조회, 비밀번호 변경) #12
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.User; | ||
| import com.loopers.domain.user.UserService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserFacade { | ||
|
|
||
| private final UserService userService; | ||
|
|
||
| public UserInfo register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { | ||
| User user = userService.register(loginId, rawPassword, name, birthDate, email); | ||
| return UserInfo.from(user); | ||
| } | ||
|
|
||
| public UserInfo getMe(String loginId, String rawPassword) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| return UserInfo.from(user); | ||
| } | ||
|
|
||
| public void changePassword(String loginId, String currentRawPassword, String newRawPassword) { | ||
| User user = userService.authenticate(loginId, currentRawPassword); | ||
| userService.changePassword(user.getId(), currentRawPassword, newRawPassword); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.User; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| public record UserInfo( | ||
| Long id, | ||
| String loginId, | ||
| String name, | ||
| String maskedName, | ||
| LocalDate birthDate, | ||
| String email | ||
| ) { | ||
| public static UserInfo from(User user) { | ||
| return new UserInfo( | ||
| user.getId(), | ||
| user.getLoginId(), | ||
| user.getName(), | ||
| user.getMaskedName(), | ||
| user.getBirthDate(), | ||
| user.getEmail() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import com.loopers.domain.BaseEntity; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Table; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeFormatter; | ||
|
|
||
| @Entity | ||
| @Table(name = "users") | ||
| public class User extends BaseEntity { | ||
|
|
||
| private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"; | ||
| private static final String LOGIN_ID_PATTERN = "^[a-zA-Z0-9]+$"; | ||
|
|
||
| @Column(name = "login_id", nullable = false, unique = true, length = 50) | ||
| private String loginId; | ||
|
|
||
| @Column(name = "password", nullable = false, length = 255) | ||
| private String password; | ||
|
|
||
| @Column(name = "name", nullable = false, length = 100) | ||
| private String name; | ||
|
|
||
| @Column(name = "birth_date", nullable = false) | ||
| private LocalDate birthDate; | ||
|
|
||
| @Column(name = "email", nullable = false, length = 255) | ||
| private String email; | ||
|
|
||
| protected User() {} | ||
|
|
||
| public User(String loginId, String encodedPassword, String name, LocalDate birthDate, String email) { | ||
| validateLoginId(loginId); | ||
| validateName(name); | ||
| validateBirthDate(birthDate); | ||
| validateEmail(email); | ||
|
|
||
| this.loginId = loginId; | ||
| this.password = encodedPassword; | ||
| this.name = name; | ||
| this.birthDate = birthDate; | ||
| this.email = email; | ||
| } | ||
|
|
||
| public static void validateRawPassword(String rawPassword, LocalDate birthDate) { | ||
| if (rawPassword == null || rawPassword.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); | ||
| } | ||
| if (rawPassword.length() < 8 || rawPassword.length() > 16) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); | ||
| } | ||
| if (!rawPassword.matches(PASSWORD_PATTERN)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용 가능합니다."); | ||
| } | ||
| if (birthDate != null && containsBirthDate(rawPassword, birthDate)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); | ||
| } | ||
| } | ||
|
|
||
| private static boolean containsBirthDate(String password, LocalDate birthDate) { | ||
| String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); | ||
| String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); | ||
| String yyyy_MM_dd = birthDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); | ||
| String yy_MM_dd = birthDate.format(DateTimeFormatter.ofPattern("yy-MM-dd")); | ||
|
|
||
| return password.contains(yyyyMMdd) || | ||
| password.contains(yyMMdd) || | ||
| password.contains(yyyy_MM_dd) || | ||
| password.contains(yy_MM_dd); | ||
| } | ||
|
|
||
| private void validateLoginId(String loginId) { | ||
| if (loginId == null || loginId.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); | ||
| } | ||
| if (!loginId.matches(LOGIN_ID_PATTERN)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 사용 가능합니다."); | ||
| } | ||
| } | ||
|
|
||
| private void validateName(String name) { | ||
| if (name == null || name.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); | ||
| } | ||
| } | ||
|
|
||
| private void validateBirthDate(LocalDate birthDate) { | ||
| if (birthDate == null) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); | ||
| } | ||
| if (birthDate.isAfter(LocalDate.now())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래일 수 없습니다."); | ||
| } | ||
| } | ||
|
|
||
| private void validateEmail(String email) { | ||
| if (email == null || email.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); | ||
| } | ||
| if (!email.matches("^[^@]+@[^@]+\\.[^@]+$")) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); | ||
| } | ||
| } | ||
|
|
||
| public void changePassword(String newEncodedPassword) { | ||
| this.password = newEncodedPassword; | ||
| } | ||
|
|
||
| public String getMaskedName() { | ||
| if (name == null || name.isEmpty()) { | ||
| return name; | ||
| } | ||
| if (name.length() == 1) { | ||
| return "*"; | ||
| } | ||
| return name.substring(0, name.length() - 1) + "*"; | ||
| } | ||
|
|
||
| public String getLoginId() { | ||
| return loginId; | ||
| } | ||
|
|
||
| public String getPassword() { | ||
| return password; | ||
| } | ||
|
|
||
| public String getName() { | ||
| return name; | ||
| } | ||
|
|
||
| public LocalDate getBirthDate() { | ||
| return birthDate; | ||
| } | ||
|
|
||
| public String getEmail() { | ||
| return email; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface UserRepository { | ||
| User save(User user); | ||
| Optional<User> findById(Long id); | ||
| Optional<User> findByLoginId(String loginId); | ||
| boolean existsByLoginId(String loginId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserService { | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); | ||
|
|
||
| @Transactional | ||
| public User register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { | ||
| if (userRepository.existsByLoginId(loginId)) { | ||
| throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); | ||
| } | ||
|
|
||
| User.validateRawPassword(rawPassword, birthDate); | ||
| String encodedPassword = passwordEncoder.encode(rawPassword); | ||
|
|
||
| User user = new User(loginId, encodedPassword, name, birthDate, email); | ||
| return userRepository.save(user); | ||
|
Comment on lines
+21
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "UserService.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 159 🏁 Script executed: find . -type f -name "User.java" -path "*/domain/user/*" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 152 🏁 Script executed: find . -type f -name "UserRepository.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 162 🏁 Script executed: find . -type f -name "CoreException.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 163 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3255 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/User.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5816 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 411 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 741 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java | head -25Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 741 🏁 Script executed: rg "public CoreException" apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 357 동시 가입 시 중복 ID가 500으로 노출되는 레이스 컨디션이 존재한다. 운영에서 동일 loginId 요청이 거의 동시에 들어오면 existsByLoginId 검사를 통과한 뒤 save 시점에 데이터베이스 유니크 제약 위반(DataIntegrityViolationException)이 발생해 500으로 노출될 수 있다. User.java의 loginId 컬럼에 수정안은 save 구간을 try/catch로 감싸 DataIntegrityViolationException을 CoreException(CONFLICT)로 변환하되, 다음 두 가지 중 선택해야 한다:
try {
if (userRepository.existsByLoginId(loginId)) {
throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
}
User.validateRawPassword(rawPassword, birthDate);
String encodedPassword = passwordEncoder.encode(rawPassword);
User user = new User(loginId, encodedPassword, name, birthDate, email);
return userRepository.save(user);
} catch (DataIntegrityViolationException ex) {
CoreException ce = new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
ce.initCause(ex);
throw ce;
}
추가 테스트로 동일 loginId를 동시 또는 연속 등록하는 통합 테스트에서 항상 CONFLICT 응답이 반환되는지 검증한다. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public User getUser(Long id) { | ||
| return userRepository.findById(id) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public User getUserByLoginId(String loginId) { | ||
| return userRepository.findByLoginId(loginId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public User authenticate(String loginId, String rawPassword) { | ||
| User user = getUserByLoginId(loginId); | ||
| if (!passwordEncoder.matches(rawPassword, user.getPassword())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); | ||
| } | ||
| return user; | ||
| } | ||
|
|
||
| @Transactional | ||
| public void changePassword(Long userId, String currentRawPassword, String newRawPassword) { | ||
| User user = getUser(userId); | ||
|
|
||
| if (!passwordEncoder.matches(currentRawPassword, user.getPassword())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); | ||
| } | ||
|
|
||
| if (passwordEncoder.matches(newRawPassword, user.getPassword())) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."); | ||
| } | ||
|
|
||
| User.validateRawPassword(newRawPassword, user.getBirthDate()); | ||
| String newEncodedPassword = passwordEncoder.encode(newRawPassword); | ||
| user.changePassword(newEncodedPassword); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.infrastructure.user; | ||
|
|
||
| import com.loopers.domain.user.User; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public interface UserJpaRepository extends JpaRepository<User, Long> { | ||
| Optional<User> findByLoginId(String loginId); | ||
| boolean existsByLoginId(String loginId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.loopers.infrastructure.user; | ||
|
|
||
| import com.loopers.domain.user.User; | ||
| import com.loopers.domain.user.UserRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserRepositoryImpl implements UserRepository { | ||
|
|
||
| private final UserJpaRepository userJpaRepository; | ||
|
|
||
| @Override | ||
| public User save(User user) { | ||
| return userJpaRepository.save(user); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<User> findById(Long id) { | ||
| return userJpaRepository.findById(id); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<User> findByLoginId(String loginId) { | ||
| return userJpaRepository.findByLoginId(loginId); | ||
| } | ||
|
|
||
| @Override | ||
| public boolean existsByLoginId(String loginId) { | ||
| return userJpaRepository.existsByLoginId(loginId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.loopers.interfaces.api.user; | ||
|
|
||
| import com.loopers.interfaces.api.ApiResponse; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
|
|
||
| @Tag(name = "User", description = "사용자 API") | ||
| public interface UserV1ApiSpec { | ||
|
|
||
| @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") | ||
| ApiResponse<UserV1Dto.RegisterResponse> register(UserV1Dto.RegisterRequest request); | ||
|
|
||
| @Operation(summary = "내 정보 조회", description = "로그인한 사용자의 정보를 조회합니다. 이름은 마스킹 처리됩니다.") | ||
| ApiResponse<UserV1Dto.UserResponse> getMe( | ||
| @Parameter(description = "로그인 ID", required = true) String loginId, | ||
| @Parameter(description = "비밀번호", required = true) String password | ||
| ); | ||
|
|
||
| @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") | ||
| ApiResponse<Void> changePassword( | ||
| @Parameter(description = "로그인 ID", required = true) String loginId, | ||
| @Parameter(description = "비밀번호", required = true) String password, | ||
| UserV1Dto.ChangePasswordRequest request | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 300
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 152
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1118
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 154
🏁 Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/User.java | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4493
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 158
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3860
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3255
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 576
비밀번호 정책이 영문/숫자/특수문자 포함 요구사항을 보장하지 못한다.
현재 PASSWORD_PATTERN은 허용되는 문자 집합만 검증하고, 영문과 숫자와 특수문자가 각각 최소 1자 이상 포함되었는지 확인하지 않는다. 따라서 "password12"(특수문자 없음) 또는 "Special!!!!"(영문/숫자 없음) 같은 약한 비밀번호가 통과하여 계정 탈취 위험이 증가한다.
validateRawPassword() 메서드의 PASSWORD_PATTERN 검증 후에 다음을 추가하여 각 카테고리 포함을 확인해야 한다:
.*[A-Za-z].*.*\\d.*.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*세 패턴 모두 매칭되지 않으면 "비밀번호는 영문/숫자/특수문자를 각각 1자 이상 포함해야 합니다."로 오류를 발생시킨다.
UserTest.java의 ValidateRawPassword 테스트에 다음 케이스를 추가하여 예외 발생 여부를 확인해야 한다:
🤖 Prompt for AI Agents