Skip to content
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
3 changes: 3 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ dependencies {
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

// security (for password encoding)
implementation("org.springframework.security:spring-security-crypto")

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
Expand Down
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()
);
}
}
143 changes: 143 additions & 0 deletions apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
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, "비밀번호에 생년월일을 포함할 수 없습니다.");
}
Comment on lines +50 to +62
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "User.java" --path "**/domain/user/**" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 300


🏁 Script executed:

find . -type f -name "User.java" | grep -i domain

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 152


🏁 Script executed:

git ls-files | grep -i "user.*\.java" | grep -E "(domain|user)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1118


🏁 Script executed:

wc -l apps/commerce-api/src/main/java/com/loopers/domain/user/User.java

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 -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4493


🏁 Script executed:

wc -l apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 158


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java | grep -A 20 -B 5 "validateRawPassword\|ValidateRawPassword"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3860


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3255


🏁 Script executed:

cat -n apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java | sed -n '105,120p'

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
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/User.java` around
lines 50 - 62, validateRawPassword currently only checks allowed characters via
PASSWORD_PATTERN but does not enforce that a password contains at least one
letter, one digit, and one special character; update validateRawPassword (in
class User) to, after the PASSWORD_PATTERN check, validate three additional
regexes (letter: .* [A-Za-z].*, digit: .*\\d.*, special:
.*[!@#$%^&*()_+\\-=\\[\\]{};':"\\\\|,.<>/?].*) and throw new
CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문/숫자/특수문자를 각각 1자 이상 포함해야 합니다.") if
any of them fail; also add unit tests in UserTest.java covering passwords with
only letters, only digits, only special chars, and letters+digits (no special)
to assert that validateRawPassword throws the expected exception.

}

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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "UserService.java" | head -20

Repository: 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 -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 152


🏁 Script executed:

find . -type f -name "UserRepository.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 162


🏁 Script executed:

find . -type f -name "CoreException.java" | head -20

Repository: 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.java

Repository: 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.java

Repository: 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.java

Repository: 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.java

Repository: 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 -25

Repository: 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 2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 357


동시 가입 시 중복 ID가 500으로 노출되는 레이스 컨디션이 존재한다.

운영에서 동일 loginId 요청이 거의 동시에 들어오면 existsByLoginId 검사를 통과한 뒤 save 시점에 데이터베이스 유니크 제약 위반(DataIntegrityViolationException)이 발생해 500으로 노출될 수 있다. User.java의 loginId 컬럼에 unique = true 제약이 설정되어 있어 이 시나리오는 실제로 발생한다.

수정안은 save 구간을 try/catch로 감싸 DataIntegrityViolationException을 CoreException(CONFLICT)로 변환하되, 다음 두 가지 중 선택해야 한다:

  1. initCause() 사용 (현재 CoreException 구조에서 가능):
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;
}
  1. CoreException 확장 (권장): ErrorType, 사용자 메시지, cause를 모두 받는 생성자 추가.

추가 테스트로 동일 loginId를 동시 또는 연속 등록하는 통합 테스트에서 항상 CONFLICT 응답이 반환되는지 검증한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java`
around lines 21 - 30, The register method in UserService (UserService.register)
can hit a race where existsByLoginId passes but userRepository.save throws
DataIntegrityViolationException due to the unique loginId constraint; wrap the
save logic in a try/catch that catches DataIntegrityViolationException and
rethrow a CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.") preserving the
original exception as the cause (either via ce.initCause(ex) or by adding a new
CoreException constructor that accepts a cause), and add an integration test
that concurrently attempts to create the same loginId to assert a CONFLICT
response is always returned.

}

@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
);
}
Loading