From 172e651543bdf536e6e3523e576cc261f41701a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=9A=B1?= Date: Mon, 3 Nov 2025 17:57:01 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20=EB=B0=B0=ED=8F=AC=EA=B0=80=20d?= =?UTF-8?q?ev=EB=B8=8C=EB=9E=9C=EC=B9=98=EC=97=90=EC=84=9C=EB=8F=84=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # .github/workflows/deploy.yml --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72abb50..00c15bc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: CI/CD with AWS ECR on: push: branches: - - dev + - main # 워크플로우에서 사용할 공통 변수 설정 env: From 68dd0da3e942a06f9bc4ab62bc7eebecd4cae6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=9A=B1?= Date: Mon, 24 Nov 2025 20:11:51 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20AccountLock=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=20=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../das/login/entity/AccountLock.java | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/src/main/java/until/the/eternity/das/login/entity/AccountLock.java b/src/main/java/until/the/eternity/das/login/entity/AccountLock.java index fd3ff35..c0fbc19 100644 --- a/src/main/java/until/the/eternity/das/login/entity/AccountLock.java +++ b/src/main/java/until/the/eternity/das/login/entity/AccountLock.java @@ -1,9 +1,19 @@ package until.the.eternity.das.login.entity; - -import jakarta.persistence.*; -import lombok.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; import until.the.eternity.das.user.entity.User; @@ -17,26 +27,46 @@ @Builder public class AccountLock { - @Id - @Column(name = "user_id") - @Comment("사용자 ID") - private Long userId; + @Id + @Column(name = "user_id") + @Comment("사용자 ID") + private Long userId; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "failed_attempts", nullable = false) + @Builder.Default + @Comment("실패 시도 횟수") + private Integer failedAttempts = 0; + + @Column(name = "locked_until") + @Comment("잠금 해제 예정 시각") + private LocalDateTime lockedUntil; - @OneToOne(fetch = FetchType.LAZY) - @MapsId - @JoinColumn(name = "user_id") - private User user; + @Column(name = "updated_at", nullable = false) + @Comment("최근 업데이트 시각") + private LocalDateTime updatedAt; - @Column(name = "failed_attempts", nullable = false) - @Builder.Default - @Comment("실패 시도 횟수") - private Integer failedAttempts = 0; + // 실패 횟수 증가 및 잠금 처리 로직 + public void increaseFailAttempts() { + this.failedAttempts++; + this.updatedAt = LocalDateTime.now(); + } - @Column(name = "locked_until") - @Comment("잠금 해제 예정 시각") - private LocalDateTime lockedUntil; + // 계정 잠금 설정 + public void lockAccount() { + this.lockedUntil = LocalDateTime.now() + .plusMinutes(5); + this.updatedAt = LocalDateTime.now(); + } - @Column(name = "updated_at", nullable = false) - @Comment("최근 업데이트 시각") - private LocalDateTime updatedAt; + // 로그인 성공 시 상태 초기화 + public void reset() { + this.failedAttempts = 0; + this.lockedUntil = null; + this.updatedAt = LocalDateTime.now(); + } } \ No newline at end of file From 99310ed7430bc8a62dc76668aa4c68b87ca8eafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=9A=B1?= Date: Mon, 24 Nov 2025 20:12:08 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=20=ED=95=B4=EB=8B=B9=ED=95=98=EB=8A=94=20JPA=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eternity/das/login/entity/AccountLockRepository.java | 6 ++++++ .../eternity/das/login/entity/LoginHistoryRepository.java | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/until/the/eternity/das/login/entity/AccountLockRepository.java create mode 100644 src/main/java/until/the/eternity/das/login/entity/LoginHistoryRepository.java diff --git a/src/main/java/until/the/eternity/das/login/entity/AccountLockRepository.java b/src/main/java/until/the/eternity/das/login/entity/AccountLockRepository.java new file mode 100644 index 0000000..63649ac --- /dev/null +++ b/src/main/java/until/the/eternity/das/login/entity/AccountLockRepository.java @@ -0,0 +1,6 @@ +package until.the.eternity.das.login.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AccountLockRepository extends JpaRepository { +} diff --git a/src/main/java/until/the/eternity/das/login/entity/LoginHistoryRepository.java b/src/main/java/until/the/eternity/das/login/entity/LoginHistoryRepository.java new file mode 100644 index 0000000..a71064c --- /dev/null +++ b/src/main/java/until/the/eternity/das/login/entity/LoginHistoryRepository.java @@ -0,0 +1,6 @@ +package until.the.eternity.das.login.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoginHistoryRepository extends JpaRepository { +} From 1a692795afbdb850ac12377fb16331ea6e573728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=9A=B1?= Date: Mon, 24 Nov 2025 20:12:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=98=88=EC=99=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../the/eternity/das/common/exception/GlobalExceptionCode.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/until/the/eternity/das/common/exception/GlobalExceptionCode.java b/src/main/java/until/the/eternity/das/common/exception/GlobalExceptionCode.java index 896c65b..ae9dc64 100644 --- a/src/main/java/until/the/eternity/das/common/exception/GlobalExceptionCode.java +++ b/src/main/java/until/the/eternity/das/common/exception/GlobalExceptionCode.java @@ -28,6 +28,8 @@ public enum GlobalExceptionCode implements ExceptionCode { USER_ROLE_NOT_EXISTS(HttpStatus.BAD_REQUEST, "USER Role이 없습니다."), ADMIN_ROLE_NOT_EXISTS(HttpStatus.BAD_REQUEST, "ADMIN Role이 DB에 존재하지 않습니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "잘못된 비밀번호 입니다."), + USER_DISABLED(UNAUTHORIZED, "비활성화된 사용자입니다."), + ACCOUNT_LOCKED(UNAUTHORIZED, "계정이 잠금상태입니다."), // S3 FILE_EMPTY(HttpStatus.BAD_REQUEST, "파일이 존재하지 않습니다."), From c04c28ecb4e10dc265d52571c7da2d90ae0aa858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=9A=B1?= Date: Mon, 24 Nov 2025 20:13:48 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20aop=EB=8C=80=EC=8B=A0=EC=97=90=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=99=9C=EC=84=B1=EC=97=AC=EB=B6=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/UserAuthenticationFilter.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/java/until/the/eternity/das/common/filter/UserAuthenticationFilter.java b/src/main/java/until/the/eternity/das/common/filter/UserAuthenticationFilter.java index 4406dbe..5e1db0d 100644 --- a/src/main/java/until/the/eternity/das/common/filter/UserAuthenticationFilter.java +++ b/src/main/java/until/the/eternity/das/common/filter/UserAuthenticationFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -14,19 +15,25 @@ import org.springframework.web.filter.OncePerRequestFilter; import until.the.eternity.das.common.constant.JwtConstant; import until.the.eternity.das.common.exception.CustomException; +import until.the.eternity.das.common.exception.GlobalExceptionCode; import until.the.eternity.das.common.util.JwtUtil; +import until.the.eternity.das.user.entity.User; +import until.the.eternity.das.user.entity.UserRepository; +import until.the.eternity.das.user.entity.enums.Status; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.List; +@Slf4j @Component @RequiredArgsConstructor public class UserAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final JwtConstant jwtConstant; + private final UserRepository userRepository; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -45,6 +52,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Long userId = jwtUtil.getUserIdFromToken(token); String role = jwtUtil.getRoleFromToken(token); + validateUserStatus(userId); + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role); List authorities = Collections.singletonList(authority); @@ -86,4 +95,16 @@ private String extractTokenFromCookie(HttpServletRequest request) { .findFirst() .orElse(null); } + + private void validateUserStatus(Long userId) { + // 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); + + // 활성 상태 검증 + if (user.getStatus() != Status.ACTIVE) { + log.warn("비활성화된 사용자 접근 시도: userId={}", userId); + throw new CustomException(GlobalExceptionCode.USER_DISABLED); + } + } } From 8769fc44546a09383406aed98c4191ce04bce414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=9A=B1?= Date: Mon, 24 Nov 2025 20:15:56 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EA=B3=84=EC=A0=95=205?= =?UTF-8?q?=EB=B6=84=20=EC=9E=A0=EA=B8=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../das/auth/application/AuthService.java | 71 ++++++++++++++++++- .../das/auth/presentation/AuthController.java | 9 ++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/main/java/until/the/eternity/das/auth/application/AuthService.java b/src/main/java/until/the/eternity/das/auth/application/AuthService.java index 6e83413..a7e82eb 100644 --- a/src/main/java/until/the/eternity/das/auth/application/AuthService.java +++ b/src/main/java/until/the/eternity/das/auth/application/AuthService.java @@ -14,6 +14,11 @@ import until.the.eternity.das.common.exception.CustomException; import until.the.eternity.das.common.exception.GlobalExceptionCode; import until.the.eternity.das.common.util.JwtUtil; +import until.the.eternity.das.login.entity.AccountLock; +import until.the.eternity.das.login.entity.AccountLockRepository; +import until.the.eternity.das.login.entity.LoginHistory; +import until.the.eternity.das.login.entity.LoginHistoryRepository; +import until.the.eternity.das.login.entity.enums.Reason; import until.the.eternity.das.role.entity.Role; import until.the.eternity.das.role.entity.RoleRepository; import until.the.eternity.das.role.entity.enums.Name; @@ -21,6 +26,8 @@ import until.the.eternity.das.user.entity.User; import until.the.eternity.das.user.entity.UserRepository; +import java.time.LocalDateTime; + @Slf4j @Service @RequiredArgsConstructor @@ -29,6 +36,8 @@ public class AuthService { private final AuthConverter authConverter; private final UserRepository userRepository; private final RoleRepository roleRepository; + private final AccountLockRepository accountLockRepository; + private final LoginHistoryRepository loginHistoryRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; private final JwtUtil jwtUtil; private final TokenService tokenService; @@ -63,14 +72,35 @@ public SignUpResponse signUpAdmin(SignUpRequest request) { } @Transactional - public LoginResultResponse login(LoginRequest request) { + public LoginResultResponse login(LoginRequest request, String clientIp, String userAgent) { User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); + AccountLock accountLock = accountLockRepository.findById(user.getId()) + .orElseGet(() -> { + AccountLock newLock = AccountLock.builder() + .user(user) + .userId(user.getId()) + .failedAttempts(0) + .updatedAt(LocalDateTime.now()) + .build(); + return accountLockRepository.save(newLock); + }); + + if (accountLock.getLockedUntil() != null && accountLock.getLockedUntil() + .isAfter(LocalDateTime.now())) { + // 잠금 상태인 경우 이력 저장 후 예외 발생 + saveLoginHistory(user, false, Reason.LOCKED_ACCOUNT, clientIp, userAgent); // Reason에 LOCKED_ACCOUNT가 없으면 추가 필요 + throw new CustomException(GlobalExceptionCode.ACCOUNT_LOCKED); // GlobalExceptionCode에 추가 필요 + } + if (!bCryptPasswordEncoder.matches(request.password(), user.getPasswordHash())) { + handleLoginFailure(user, accountLock, clientIp, userAgent); throw new CustomException(GlobalExceptionCode.INVALID_PASSWORD); } + handleLoginSuccess(user, accountLock, clientIp, userAgent); + String accessToken = jwtUtil.generateAccessToken(user); String refreshToken = jwtUtil.generateRefreshToken(user); @@ -84,6 +114,45 @@ public LoginResultResponse login(LoginRequest request) { .build(); } + /** + * 로그인 실패 핸들링 + */ + private void handleLoginFailure(User user, AccountLock accountLock, String ip, String userAgent) { + accountLock.increaseFailAttempts(); // 실패 횟수 증가 + + // 5회 이상 실패 시 5분 잠금 + if (accountLock.getFailedAttempts() >= 5) { + accountLock.lockAccount(); // 5분 잠금 + } + + // Dirty Checking으로 AccountLock 업데이트 됨 (Transactional) + saveLoginHistory(user, false, Reason.WRONG_PASSWORD, ip, userAgent); // Reason.WRONG_PASSWORD 확인 필요 + } + + /** + * 로그인 성공 핸들링 + */ + private void handleLoginSuccess(User user, AccountLock accountLock, String ip, String userAgent) { + accountLock.reset(); // 실패 횟수 및 잠금 초기화 + saveLoginHistory(user, true, null, ip, userAgent); + } + + /** + * 로그인 이력 저장 + */ + private void saveLoginHistory(User user, boolean success, Reason reason, String ip, String userAgent) { + LoginHistory history = LoginHistory.builder() + .user(user) + .success(success) + .reason(reason) + .loginIp(ip) + .userAgent(userAgent) + .createdAt(LocalDateTime.now()) + .build(); + + loginHistoryRepository.save(history); + } + private SignUpResponse signUp(SignUpRequest request, Role role) { // 이메일 형식 유효성 검증 isValidEmailFormat(request.email()); diff --git a/src/main/java/until/the/eternity/das/auth/presentation/AuthController.java b/src/main/java/until/the/eternity/das/auth/presentation/AuthController.java index 3d3705c..e1c8579 100644 --- a/src/main/java/until/the/eternity/das/auth/presentation/AuthController.java +++ b/src/main/java/until/the/eternity/das/auth/presentation/AuthController.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; @@ -163,9 +164,13 @@ public ResponseEntity> completeSocialSignup( content = @Content(schema = @Schema(implementation = LoginResponse.class))) public ResponseEntity> login( @RequestBody LoginRequest request, - HttpServletResponse response + HttpServletResponse response, + HttpServletRequest httpServletRequest ) { - LoginResultResponse loginResultResponse = authService.login(request); + String clientIp = httpServletRequest.getRemoteAddr(); + String userAgent = httpServletRequest.getHeader("User-Agent"); + + LoginResultResponse loginResultResponse = authService.login(request, clientIp, userAgent); cookieUtil.createAccessTokenCookie(response, loginResultResponse.accessToken()); cookieUtil.createRefreshTokenCookie(response, loginResultResponse.refreshToken());