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: 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()); 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, "파일이 존재하지 않습니다."), 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); + } + } } 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 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 { +}