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
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: CI/CD with AWS ECR
on:
push:
branches:
- dev
- main
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

[nitpick] The workflow file change from dev to main branch appears unrelated to the account locking feature described in the PR title and description. This change affects deployment triggers and should ideally be in a separate PR or explicitly mentioned in the PR description to explain why it's included.

Suggested change
- main
- dev

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

dev에 PR시에는 개발서버, main에 PR시에는 운영서버로 배포가 될 예정이니 workflows 파일을 분리해서 deploy-dev, deploy-main 등으로 관리하면 좋을 것 같습니다.


# 워크플로우에서 사용할 공통 변수 설정
env:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@
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;
import until.the.eternity.das.token.application.TokenService;
import until.the.eternity.das.user.entity.User;
import until.the.eternity.das.user.entity.UserRepository;

import java.time.LocalDateTime;

@Slf4j
@Service
@RequiredArgsConstructor
Expand All @@ -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;
Expand Down Expand Up @@ -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에 추가 필요
Comment on lines +93 to +94
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

The inline comments on lines 93 and 94 should be removed as they are redundant. The enum values Reason.LOCKED_ACCOUNT and exception code GlobalExceptionCode.ACCOUNT_LOCKED already exist (as shown in the diff), so these comments suggesting they need to be added are misleading and should be deleted.

Suggested change
saveLoginHistory(user, false, Reason.LOCKED_ACCOUNT, clientIp, userAgent); // Reason에 LOCKED_ACCOUNT가 없으면 추가 필요
throw new CustomException(GlobalExceptionCode.ACCOUNT_LOCKED); // GlobalExceptionCode에 추가 필요
saveLoginHistory(user, false, Reason.LOCKED_ACCOUNT, clientIp, userAgent);
throw new CustomException(GlobalExceptionCode.ACCOUNT_LOCKED);

Copilot uses AI. Check for mistakes.
}

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);

Expand All @@ -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 확인 필요
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

The comment on line 129 "// Reason.WRONG_PASSWORD 확인 필요" (needs verification) should be removed. The Reason.WRONG_PASSWORD enum value already exists in the codebase, so this verification comment is unnecessary and can be deleted.

Suggested change
saveLoginHistory(user, false, Reason.WRONG_PASSWORD, ip, userAgent); // Reason.WRONG_PASSWORD 확인 필요
saveLoginHistory(user, false, Reason.WRONG_PASSWORD, ip, userAgent);

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

이제 주석이 불필요해진 것 같네요. 주석 삭제하셔도 될 것 같습니다.

}

/**
* 로그인 성공 핸들링
*/
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -163,9 +164,13 @@ public ResponseEntity<CommonResponse<SignUpResponse>> completeSocialSignup(
content = @Content(schema = @Schema(implementation = LoginResponse.class)))
public ResponseEntity<CommonResponse<LoginResponse>> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "파일이 존재하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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<GrantedAuthority> authorities = Collections.singletonList(authority);

Expand Down Expand Up @@ -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);
}
}
}
72 changes: 51 additions & 21 deletions src/main/java/until/the/eternity/das/login/entity/AccountLock.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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();
Comment on lines +61 to +63
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

[nitpick] The lockAccount() method calls LocalDateTime.now() twice (lines 61 and 63), which could result in slightly different timestamps for lockedUntil and updatedAt. While the difference would be minimal, it's more efficient and consistent to call LocalDateTime.now() once and reuse the value:

public void lockAccount() {
  LocalDateTime now = LocalDateTime.now();
  this.lockedUntil = now.plusMinutes(5);
  this.updatedAt = now;
}
Suggested change
this.lockedUntil = LocalDateTime.now()
.plusMinutes(5);
this.updatedAt = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now();
this.lockedUntil = now.plusMinutes(5);
this.updatedAt = now;

Copilot uses AI. Check for mistakes.
}

@Column(name = "updated_at", nullable = false)
@Comment("최근 업데이트 시각")
private LocalDateTime updatedAt;
// 로그인 성공 시 상태 초기화
public void reset() {
this.failedAttempts = 0;
this.lockedUntil = null;
this.updatedAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package until.the.eternity.das.login.entity;

import org.springframework.data.jpa.repository.JpaRepository;

public interface AccountLockRepository extends JpaRepository<AccountLock, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package until.the.eternity.das.login.entity;

import org.springframework.data.jpa.repository.JpaRepository;

public interface LoginHistoryRepository extends JpaRepository<LoginHistory, Long> {
}
Loading