From 465a1d1adf42f540fac1c4618f68c8e3def9dcd9 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 31 Jul 2026 20:08:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Feat]=20mau=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 21 ++++++ .../domain/auth/service/AuthServiceTest.java | 72 ++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java b/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java index 0ea72e08..32602ebb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.auth.service; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; import com.jobdri.jobdri_api.domain.auth.dto.request.LoginRequest; import com.jobdri.jobdri_api.domain.auth.dto.request.LogoutRequest; import com.jobdri.jobdri_api.domain.auth.dto.request.PasswordResetConfirmationRequest; @@ -17,6 +18,7 @@ import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.redis.core.StringRedisTemplate; @@ -30,9 +32,11 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; +import java.util.Map; import java.util.concurrent.TimeUnit; @Service +@Slf4j @RequiredArgsConstructor public class AuthService { @@ -54,6 +58,7 @@ public class AuthService { private final EmailService emailService; private final AsyncEmailSender asyncEmailSender; private final StringRedisTemplate redisTemplate; + private final AuditLogService auditLogService; @Transactional public void signup(SignupRequest request) { @@ -98,6 +103,7 @@ public LoginResponse issueTokens(User user) { String refreshTokenValue = jwtUtil.createRefreshToken(user.getEmail()); saveRefreshToken(user.getId(), refreshTokenValue); + recordLoginSuccess(user); return LoginResponse.builder() .accessToken(accessToken) @@ -261,6 +267,21 @@ private void saveRefreshToken(Long userId, String refreshTokenValue) { ); } + private void recordLoginSuccess(User user) { + try { + auditLogService.record( + user, + "LOGIN_SUCCESS", + "USER", + user.getId(), + null, + Map.of("loginMethod", user.getSocialType().name()) + ); + } catch (RuntimeException e) { + log.warn("Login success audit log recording failed. userId={}", user.getId(), e); + } + } + private String getRefreshTokenKey(Long userId) { return REFRESH_TOKEN_PREFIX + userId; } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java index f8f71a4b..d3dd74a1 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java @@ -1,8 +1,11 @@ package com.jobdri.jobdri_api.domain.auth.service; +import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; +import com.jobdri.jobdri_api.domain.auth.dto.request.LoginRequest; import com.jobdri.jobdri_api.domain.auth.dto.request.PasswordResetConfirmationRequest; import com.jobdri.jobdri_api.domain.auth.dto.request.PasswordResetEmailRequest; import com.jobdri.jobdri_api.domain.auth.dto.request.ReissueTokenRequest; +import com.jobdri.jobdri_api.domain.auth.dto.response.LoginResponse; import com.jobdri.jobdri_api.domain.user.entity.SocialType; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; @@ -25,12 +28,14 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -61,6 +66,9 @@ class AuthServiceTest { @Mock private StringRedisTemplate redisTemplate; + @Mock + private AuditLogService auditLogService; + @Mock private ValueOperations valueOperations; @@ -75,7 +83,69 @@ void setUp() { jwtUtil, emailService, asyncEmailSender, - redisTemplate + redisTemplate, + auditLogService + ); + } + + @Test + @DisplayName("로컬 로그인 성공 시 refresh token 저장 후 로그인 성공 audit 로그를 남긴다") + void loginStoresRefreshTokenAndRecordsAuditLog() { + User user = localUser(1L, "local@example.com"); + when(userRepository.findByEmail("local@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("password123", "encoded-password")).thenReturn(true); + when(jwtUtil.createAccessToken(user.getEmail(), user.getId(), user.getRole())).thenReturn("access-token"); + when(jwtUtil.createRefreshToken(user.getEmail())).thenReturn("refresh-token"); + when(jwtUtil.getRefreshTokenTime()).thenReturn(604_800_000L); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + LoginResponse response = authService.login(new LoginRequest("local@example.com", "password123")); + + assertThat(response.accessToken()).isEqualTo("access-token"); + assertThat(response.refreshToken()).isEqualTo("refresh-token"); + verify(valueOperations).set( + "RefreshToken:1", + "refresh-token", + 604_800_000L, + TimeUnit.MILLISECONDS + ); + verify(auditLogService).record( + eq(user), + eq("LOGIN_SUCCESS"), + eq("USER"), + eq(1L), + isNull(), + eq(Map.of("loginMethod", "LOCAL")) + ); + } + + @Test + @DisplayName("소셜 로그인 토큰 발급도 Google 로그인 성공 audit 로그를 남긴다") + void issueTokensRecordsGoogleLoginAuditLog() { + User user = User.createSocialUser( + "구글 사용자", + "google@example.com", + "encoded-password", + SocialType.GOOGLE, + "google-id" + ); + ReflectionTestUtils.setField(user, "id", 2L); + when(jwtUtil.createAccessToken(user.getEmail(), user.getId(), user.getRole())).thenReturn("google-access-token"); + when(jwtUtil.createRefreshToken(user.getEmail())).thenReturn("google-refresh-token"); + when(jwtUtil.getRefreshTokenTime()).thenReturn(604_800_000L); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + LoginResponse response = authService.issueTokens(user); + + assertThat(response.accessToken()).isEqualTo("google-access-token"); + assertThat(response.refreshToken()).isEqualTo("google-refresh-token"); + verify(auditLogService).record( + eq(user), + eq("LOGIN_SUCCESS"), + eq("USER"), + eq(2L), + isNull(), + eq(Map.of("loginMethod", "GOOGLE")) ); } From 95ad5b1b8fbec9d01a2f731cfb863e261c8682c5 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 1 Aug 2026 11:42:50 +0900 Subject: [PATCH 2/3] Fix: Ensure audit logging failure does not roll back authentication --- .../domain/audit/service/AuditLogService.java | 3 +- .../domain/auth/service/AuthServiceTest.java | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java index c21e3752..cb54c910 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java @@ -11,6 +11,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -30,7 +31,7 @@ public class AuditLogService { private final AuditLogRepository auditLogRepository; private final ObjectMapper objectMapper; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void record( User user, String action, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java index d3dd74a1..a46d784f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/auth/service/AuthServiceTest.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.auth.service; +import com.jobdri.jobdri_api.domain.audit.repository.AuditLogRepository; import com.jobdri.jobdri_api.domain.audit.service.AuditLogService; import com.jobdri.jobdri_api.domain.auth.dto.request.LoginRequest; import com.jobdri.jobdri_api.domain.auth.dto.request.PasswordResetConfirmationRequest; @@ -18,6 +19,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -25,6 +28,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -34,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.anyString; @@ -328,3 +334,62 @@ private User localUser(Long id, String email) { return user; } } + +@SpringBootTest +@ActiveProfiles("test") +class AuthServiceAuditFailureIntegrationTest { + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @MockitoBean + private PasswordEncoder passwordEncoder; + + @MockitoBean + private JwtUtil jwtUtil; + + @MockitoBean + private EmailService emailService; + + @MockitoBean + private AsyncEmailSender asyncEmailSender; + + @MockitoBean + private StringRedisTemplate redisTemplate; + + @MockitoBean + private AuditLogRepository auditLogRepository; + + @Test + @DisplayName("로그인 성공 audit 저장이 실패해도 토큰 발급은 커밋된다") + void loginCommitsWhenAuditRecordingFails() { + User user = userRepository.save(User.signup( + "통합 테스트 사용자", + "audit-failure-login@example.com", + "encoded-password" + )); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(passwordEncoder.matches("password123", "encoded-password")).thenReturn(true); + when(jwtUtil.createAccessToken(user.getEmail(), user.getId(), user.getRole())).thenReturn("access-token"); + when(jwtUtil.createRefreshToken(user.getEmail())).thenReturn("refresh-token"); + when(jwtUtil.getRefreshTokenTime()).thenReturn(604_800_000L); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(auditLogRepository.save(any())).thenThrow(new RuntimeException("audit insert failed")); + + LoginResponse response = authService.login(new LoginRequest(user.getEmail(), "password123")); + + assertThat(response.accessToken()).isEqualTo("access-token"); + assertThat(response.refreshToken()).isEqualTo("refresh-token"); + verify(valueOperations).set( + "RefreshToken:" + user.getId(), + "refresh-token", + 604_800_000L, + TimeUnit.MILLISECONDS + ); + verify(auditLogRepository).save(any()); + } +} From fb7bb0006609be44e542ac7b9d9a4e2c1c116956 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 1 Aug 2026 11:47:53 +0900 Subject: [PATCH 3/3] Fix audit log user reference resolution --- .../domain/audit/service/AuditLogService.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java index cb54c910..e2b10d00 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/audit/service/AuditLogService.java @@ -5,6 +5,7 @@ import com.jobdri.jobdri_api.domain.audit.entity.AuditLog; import com.jobdri.jobdri_api.domain.audit.repository.AuditLogRepository; import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.global.logging.LoggingContext; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -30,6 +31,7 @@ public class AuditLogService { private final AuditLogRepository auditLogRepository; private final ObjectMapper objectMapper; + private final UserRepository userRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public void record( @@ -42,7 +44,7 @@ public void record( ) { HttpServletRequest request = currentRequest(); AuditLog auditLog = auditLogRepository.save(AuditLog.create( - user, + resolveVisibleUser(user), action, targetType, targetId, @@ -54,6 +56,13 @@ public void record( writeAuditTrail(auditLog); } + private User resolveVisibleUser(User user) { + if (user == null || user.getId() == null) { + return null; + } + return userRepository.findById(user.getId()).orElse(null); + } + private HttpServletRequest currentRequest() { if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { return attributes.getRequest();