Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
Expand All @@ -45,11 +44,6 @@
@EnableMethodSecurity
public class SecurityConfig {

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
Expand Down Expand Up @@ -176,10 +170,10 @@ public WebSecurityCustomizer webSecurityCustomizer() {
}

@Bean
public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService) {
public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
authProvider.setPasswordEncoder(passwordEncoder);

// 비밀번호 검증이 성공적으로 통과된 이후 호출되는 로직 설정
authProvider.setPostAuthenticationChecks(toCheck -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -50,12 +49,18 @@ public String getName() {

@Override
public boolean isAccountNonLocked() {
// 계정 정지(밴) 상태인 경우 잠금(Locked) 처리
// 계정 정지(밴) 상태인 경우에만 잠금(Locked) 처리.
return !Boolean.TRUE.equals(userResponse.isBanned());
}

@Override
public boolean isEnabled() {
// 탈퇴(논리 삭제) 진행 중이거나 완료된 계정인 경우 비활성화(Disabled) 처리
return userResponse.deletedAt() == null;
}

@Override
public Map<String, Object> getAttributes() {
return attributes;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.momogo.api.auth.details;

import com.momogo.core.common.util.EmailFormatter;
import com.momogo.core.common.exception.BusinessException;
import com.momogo.core.common.util.EmailFormatter;
import com.momogo.core.domain.user.dto.response.UserResponse;
import com.momogo.core.domain.user.entity.User;
import com.momogo.core.domain.user.exception.UserErrorCode;
import com.momogo.core.domain.user.mapper.UserMapper;
import com.momogo.core.domain.user.repository.UserRepository;
import com.momogo.core.domain.user.service.UserCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
Expand All @@ -15,7 +16,6 @@
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.Locale;

@Slf4j
@RequiredArgsConstructor
Expand All @@ -24,6 +24,7 @@ public class MoMoGoUserDetailsService implements UserDetailsService {

private final UserRepository userRepository;
private final UserMapper userMapper;
private final UserCacheService userCacheService;

@Override
@Transactional(readOnly = true)
Expand All @@ -32,16 +33,6 @@ public UserDetails loadUserByUsername(String username) {
return loadUserDetails(username);
}

/**
* JWT 토큰 검증 시 필터에서 호출하는 메서드입니다.
* 유저 정보를 조회합니다.
*/
@Transactional(readOnly = true)
public UserDetails loadUserByUsernameForToken(String username) {
log.debug("[MoMoGoUserDetailsService] loadUserByUsernameForToken 호출됨, email: {}", EmailFormatter.mask(username));
return loadUserDetails(username);
}

/**
* 공통 사용자 정보 조회 및 UserDetails 변환 메서드입니다.
* 임시 패스워드가 유효한 경우 로그인 비밀번호를 대체하며,
Expand Down Expand Up @@ -72,6 +63,22 @@ private UserDetails loadUserDetails(String username) {
return new MoMoGoUserDetails(userResponse, passwordForAuth);
}

/**
* JWT 토큰 검증 시 필터에서 호출하는 메서드입니다.
* Redis 캐시에서 UserResponse를 조회하여 DB 접근 없이 UserDetails를 즉시 반환합니다.
*/
@Transactional(readOnly = true)
public UserDetails loadUserByUsernameForToken(String username) {
log.debug("[MoMoGoUserDetailsService] loadUserByUsernameForToken 호출됨, email: {}", EmailFormatter.mask(username));
String normalizedEmail = EmailFormatter.normalize(username);

// Redis 캐시에서 UserResponse DTO 조회 (캐시 미스 시 DB 조회 후 Redis 자동 저장)
UserResponse userResponse = userCacheService.getUserResponseCached(normalizedEmail);

// 캐시된 DTO 정보로 UserDetails 즉시 생성하여 리턴 (DB 쿼리 0회)
return new MoMoGoUserDetails(userResponse, "");
}

/**
* 만료된 임시 비밀번호를 DB에서 정리(초기화)합니다.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.momogo.api.auth.details;

import com.momogo.api.auth.dto.OAuth2Attributes;
import com.momogo.core.common.util.EmailFormatter;
import com.momogo.core.domain.user.dto.response.UserResponse;
import com.momogo.core.domain.user.entity.User;
import com.momogo.core.domain.user.entity.enums.SocialType;
import com.momogo.core.domain.user.event.UserCacheEvictEvent;
import com.momogo.core.domain.user.mapper.UserMapper;
import com.momogo.core.domain.user.repository.UserRepository;
import com.momogo.core.domain.user.service.UserCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
Expand All @@ -16,8 +21,6 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.momogo.core.common.util.EmailFormatter;

import java.util.UUID;

@Slf4j
Expand All @@ -27,6 +30,9 @@ public class OAuth2UserDetailsService extends DefaultOAuth2UserService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserCacheService userCacheService;
private final UserMapper userMapper;
private final ApplicationEventPublisher eventPublisher;

/**
* 소셜(구글/카카오) 인증이 완료된 후 사용자를 조회하거나 신규 가입을 처리합니다.
Expand Down Expand Up @@ -58,29 +64,37 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic
}

SocialType socialType = SocialType.valueOf(registrationId.toUpperCase());

User user = userRepository.findByEmail(attributes.email())
.map(existingUser -> validateSocialUser(existingUser, socialType))
.orElseGet(() -> registerSocialUser(attributes, socialType));
String normalizedEmail = EmailFormatter.normalize(attributes.email());

// 이번 트랜잭션에서 유저 상태가 변경되었는지(신규 가입 또는 탈퇴 복구) 추적
boolean[] stateChanged = {false};

User user = userRepository.findByEmail(normalizedEmail)
.map(existingUser -> validateSocialUser(existingUser, socialType, stateChanged))
.orElseGet(() -> {
stateChanged[0] = true;
return registerSocialUser(attributes, socialType);
});

UserResponse userResponse;
if (stateChanged[0]) {
// 신규 가입, 복구의 경우 엔티티로부터 직접 변환한다.
userResponse = userMapper.toResponse(user);
// 캐시 갱신은 커밋 확정 이후로 위임
eventPublisher.publishEvent(new UserCacheEvictEvent(normalizedEmail));
} else {
// 상태 변경이 없는 일반 로그인: 기존 cache-aside 조회 그대로 사용 (안전함)
userResponse = userCacheService.getUserResponseCached(normalizedEmail);
}

return new MoMoGoUserDetails(
UserResponse.builder()
.id(user.getId())
.email(user.getEmail())
.name(user.getName())
.profileImageUrl(user.getProfileImageUrl())
.role(user.getRole())
.social(user.getSocial())
.isBanned(user.getIsBanned())
.createdAt(user.getCreatedAt())
.deletedAt(user.getDeletedAt())
.build(),
userResponse,
user.getPassword(),
attributes.attributes()
);
}

private User validateSocialUser(User user, SocialType socialType) {
private User validateSocialUser(User user, SocialType socialType, boolean[] stateChanged) {
if (Boolean.TRUE.equals(user.getIsBanned())) {
log.error("[OAuth2UserDetailsService] 밴 처리된 유저({}) 로그인 시도", EmailFormatter.mask(user.getEmail()));
throw new OAuth2AuthenticationException(
Expand All @@ -101,6 +115,7 @@ private User validateSocialUser(User user, SocialType socialType) {
log.info("[OAuth2UserDetailsService] 탈퇴 대기 중인 소셜 유저({}) 복구 및 로그인 진행", EmailFormatter.mask(user.getEmail()));
user.restore();
userRepository.save(user);
stateChanged[0] = true;
}

if (user.getSocial() != socialType) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package com.momogo.api.auth.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.momogo.api.auth.jwt.JwtTokenProvider;
import com.momogo.core.common.exception.AuthErrorCode;
import com.momogo.core.common.exception.BusinessException;
import com.momogo.core.domain.user.exception.UserErrorCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
Expand Down Expand Up @@ -44,13 +43,27 @@ public void onAuthenticationFailure(
errorCode = businessException.getErrorCode().getCode();
status = businessException.getErrorCode().getHttpStatus().value();

// 서버 과부하(동시성 제한)로 인한 실패는 별도 로그 레벨/메시지로 구분
if (businessException.getErrorCode() == AuthErrorCode.LOGIN_SERVER_BUSY) {
log.warn("로그인 실패(서버 과부하 동시성 제한): {}", exception.getMessage());
} else {
log.warn("로그인 실패(비즈니스 예외): {} - {}", errorCode, errorMessage);
}

} else if (exception instanceof LockedException) {
errorMessage = "정지된 계정입니다. 관리자에게 문의하세요.";
errorCode = "USER-BANNED_USER";
status = HttpServletResponse.SC_FORBIDDEN;
log.warn("로그인 실패(제한된 계정): {}", exception.getClass().getSimpleName());
// MoMoGoUserDetails.isAccountNonLocked() == false -> 정지(밴) 계정
errorMessage = UserErrorCode.BANNED_USER.getMessage();
errorCode = UserErrorCode.BANNED_USER.getCode();
status = UserErrorCode.BANNED_USER.getHttpStatus().value();
log.warn("로그인 실패(정지된 계정): {}", exception.getClass().getSimpleName());

} else if (exception instanceof DisabledException) {
log.warn("로그인 실패(비활성화 계정): {}", exception.getClass().getSimpleName());
// MoMoGoUserDetails.isEnabled() == false -> 탈퇴(논리 삭제) 계정
errorMessage = UserErrorCode.ALREADY_IN_PROGRESS_DELETE.getMessage();
errorCode = UserErrorCode.ALREADY_IN_PROGRESS_DELETE.getCode();
status = UserErrorCode.ALREADY_IN_PROGRESS_DELETE.getHttpStatus().value();
log.warn("로그인 실패(탈퇴된 계정): {}", exception.getClass().getSimpleName());

} else {
log.info("로그인 실패: {}", exception.getClass().getSimpleName());
}
Expand All @@ -67,4 +80,4 @@ public void onAuthenticationFailure(
String responseBody = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(responseBody);
}
Comment thread
jaejo marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Arrays;

Expand All @@ -31,11 +32,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return path.startsWith("/api/auth/refresh") ||
path.startsWith("/swagger-ui") ||
path.startsWith("/v3/api-docs") ||
path.startsWith("/favicon.svg") ||
path.startsWith("/assets/");
return path.startsWith("/api/auth/refresh") ||
path.startsWith("/swagger-ui") ||
path.startsWith("/v3/api-docs") ||
path.startsWith("/favicon.svg") ||
path.startsWith("/assets/");
}

@Override
Expand All @@ -53,7 +54,9 @@ protected void doFilterInternal(
MoMoGoUserDetails userDetails = tokenProvider.parseAccessToken(token);
UserDetails currentUserDetails = userDetailsService.loadUserByUsernameForToken(userDetails.getUsername());

if (currentUserDetails.isAccountNonLocked()) {
// isAccountNonLocked(): 밴(정지) 상태 여부
// isEnabled(): 탈퇴(논리 삭제) 상태 여부
if (currentUserDetails.isAccountNonLocked() && currentUserDetails.isEnabled()) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
currentUserDetails,
Expand All @@ -65,7 +68,10 @@ protected void doFilterInternal(
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
jwtRegistry.invalidateJwtInformationByUserId(userDetails.getUserResponse().id());
log.warn("[JwtFilter] 잠긴 계정 감지: userId= {}, 모든 JWT 세션 무효화", userDetails.getUserResponse().id());
log.warn("[JwtFilter] 비활성 계정(정지 또는 탈퇴) 감지: userId={}, accountNonLocked={}, enabled={}, 모든 JWT 세션 무효화",
userDetails.getUserResponse().id(),
currentUserDetails.isAccountNonLocked(),
currentUserDetails.isEnabled());
}
} else {
log.debug("[JwtFilter] 레지스트리에 존재하지 않거나 비활성화된 토큰입니다.");
Expand Down Expand Up @@ -94,4 +100,4 @@ private String resolveToken(HttpServletRequest request) {
}
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import com.momogo.api.auth.jwt.JwtRegistry;
import com.momogo.core.common.config.AsyncConfig;
import com.momogo.core.common.util.EmailFormatter;
import com.momogo.core.domain.user.event.PasswordChangedEvent;
import com.momogo.core.domain.user.event.UserBannedEvent;
import com.momogo.core.domain.user.event.UserCacheEvictEvent;
import com.momogo.core.domain.user.event.UserDeletedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
Expand All @@ -19,7 +23,10 @@
@RequiredArgsConstructor
public class UserSessionEventListener {

private static final String USER_DTO_CACHE = "user_dtos";

private final JwtRegistry jwtRegistry;
private final RedisCacheManager cacheManager;

// fallbackExecution = true 옵션을 명시하여 트랜잭션이 없을 때도 즉시 이벤트가 정상적으로 처리되도록 안전만 구축
@Async(AsyncConfig.USER_EXECUTOR)
Expand Down Expand Up @@ -51,4 +58,24 @@ private void invalidateSession(UUID userId, String eventName) {
log.error("[UserSessionEventListener] JWT 세션 무효화 실패, event: {} userId: {}", eventName, userId, e);
}
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handleUserCacheEvictEvent(UserCacheEvictEvent event) {
if (event == null || event.email() == null) {
return;
}
String normalizedEmail = EmailFormatter.normalize(event.email());
log.info("[UserSessionEventListener] 유저 캐시 무효화 이벤트 감지 - email: {}", EmailFormatter.mask(normalizedEmail));

try {
Cache cache = cacheManager.getCache(USER_DTO_CACHE);
if (cache != null) {
cache.evict(normalizedEmail);
} else {
log.warn("[UserSessionEventListener] {} 캐시를 찾을 수 없어 evict를 건너뜁니다.", USER_DTO_CACHE);
}
} catch (Exception e) {
log.error("[UserSessionEventListener] Redis 캐시 무효화 중 예외 발생 - email: {}", EmailFormatter.mask(normalizedEmail), e);
}
}
Comment thread
jaejo marked this conversation as resolved.
}
Loading