diff --git a/src/main/java/com/server/auth/config/AuthCookieProperties.java b/src/main/java/com/server/auth/config/AuthCookieProperties.java new file mode 100644 index 00000000..4ff0b275 --- /dev/null +++ b/src/main/java/com/server/auth/config/AuthCookieProperties.java @@ -0,0 +1,29 @@ +package com.server.auth.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.cookie.auth") +public class AuthCookieProperties { + + private boolean secure; + private String sameSite; + private String path; + private long accessMaxAgeSeconds; + private long refreshMaxAgeSeconds; + + @PostConstruct // 앱 뜰 때 딱 1번 찍힘 (등록+바인딩 검증) + public void logLoadedValues() { + log.info("[AuthCookieProperties] loaded. secure={}, sameSite={}, path={}, accessMaxAgeSeconds={}, refreshMaxAgeSeconds={}", + secure, sameSite, path, accessMaxAgeSeconds, refreshMaxAgeSeconds); + } +} + diff --git a/src/main/java/com/server/auth/controller/AuthController.java b/src/main/java/com/server/auth/controller/AuthController.java index 6c314270..c21e2579 100644 --- a/src/main/java/com/server/auth/controller/AuthController.java +++ b/src/main/java/com/server/auth/controller/AuthController.java @@ -1,5 +1,7 @@ package com.server.auth.controller; + +import com.server.auth.config.AuthCookieProperties; import com.server.auth.dto.*; import com.server.auth.service.AuthService; import com.server.auth.service.PasswordResetService; @@ -28,6 +30,8 @@ public class AuthController { private final AuthService authService; private final PasswordResetService passwordResetService; + private final AuthCookieProperties authCookieProperties; + // 비밀번호 재설정 이메일 발송 @Operation( @@ -58,54 +62,46 @@ public CommonResponse confirmPasswordReset( } - //로그인 - @Operation( - summary = "로그인 및 토큰 발급") + //로그인 + @Operation( + summary = "로그인 및 토큰 발급") @PostMapping("/token") public CommonResponse login( @Valid @RequestBody UserLoginRequestDto request, HttpServletResponse httpServletResponse ) { - UserLoginResponseDto response = authService.login( - request.email(), - request.password() - ); - - // Access Token 쿠키 - ResponseCookie accessCookie = ResponseCookie.from("accessToken", response.getAccessToken()) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(60 * 60) // 1시간 - .build(); - - // Refresh Token 쿠키 - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", response.getRefreshToken()) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(60L * 60 * 24 * 7) // 7일 - .build(); + UserLoginResponseDto issued = authService.login(request.email(), request.password()); - httpServletResponse.addHeader("Set-Cookie", accessCookie.toString()); - httpServletResponse.addHeader("Set-Cookie", refreshCookie.toString()); + // 쿠키 세팅 로직 + setAuthCookies(httpServletResponse, issued.getAccessToken(), issued.getRefreshToken()); - return CommonResponse.success(response); + // refreshToken 바디 제거 + UserLoginResponseDto body = UserLoginResponseDto.of(issued.getAccessToken(), null); + return CommonResponse.success(body); } @Operation( summary = "Access Token 재발급") @PostMapping("/token/refresh") public CommonResponse refresh( - @Valid @RequestBody TokenRefreshRequestDto request + // 쿠키 기반으로 바꾸기 위해 Request/Response를 받음 + HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse ) { - UserLoginResponseDto response = authService.reissueAccessToken( - request.refreshToken() - ); - return CommonResponse.success(response); + // refreshToken을 요청 바디가 아니라 쿠키에서 꺼냄 + String refreshToken = extractCookie(httpServletRequest, "refreshToken"); + + // 쿠키에서 꺼낸 refreshToken으로 재발급 + UserLoginResponseDto reissued = authService.reissueAccessToken(refreshToken); + + // 쿠키 세팅 로직 + setAuthCookies(httpServletResponse, reissued.getAccessToken(), reissued.getRefreshToken()); + + // refreshToken 바디 제거 + UserLoginResponseDto body = UserLoginResponseDto.of(reissued.getAccessToken(), null); + return CommonResponse.success(body); } + //로그아웃 @Operation( summary = "로그아웃") @@ -120,32 +116,49 @@ public CommonResponse logout( // 실제 쿠키 삭제 등의 로그아웃 처리 로직은 서비스에 위임 authService.logout(refreshToken); - // 쿠키 삭제 (accessToken / refreshToken 둘 다 만료시킴) - ResponseCookie clearAccess = ResponseCookie.from("accessToken", "") - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(0)// 즉시 만료 - .build(); + // 쿠키 삭제 로직 통일 + clearAuthCookies(response); - ResponseCookie clearRefresh = ResponseCookie.from("refreshToken", "") - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(0) - .build(); + // 로그아웃은 별도의 데이터가 필요 없으므로 data = null로 응답 + return CommonResponse.success("로그아웃 되었습니다."); + } - response.addHeader("Set-Cookie", clearAccess.toString()); - response.addHeader("Set-Cookie", clearRefresh.toString()); + // 헬퍼 메서드: 인증 쿠키 처리 공통화(중복 제거) + private void setAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) { + ResponseCookie accessCookie = buildCookie( + "accessToken", + accessToken, + authCookieProperties.getAccessMaxAgeSeconds() + ); - // 로그아웃은 별도의 데이터가 필요 없으므로 data = null로 응답 - return CommonResponse.success("로그아웃 되었습니다."); + ResponseCookie refreshCookie = buildCookie( + "refreshToken", + refreshToken, + authCookieProperties.getRefreshMaxAgeSeconds() + ); + + response.addHeader("Set-Cookie", accessCookie.toString()); + response.addHeader("Set-Cookie", refreshCookie.toString()); + } + + private void clearAuthCookies(HttpServletResponse response) { + ResponseCookie clearAccess = buildCookie("accessToken", "", 0); + ResponseCookie clearRefresh = buildCookie("refreshToken", "", 0); + + response.addHeader("Set-Cookie", clearAccess.toString()); + response.addHeader("Set-Cookie", clearRefresh.toString()); } - // 요청 쿠키에서 name 에 해당하는 쿠키 값을 꺼내는 헬퍼 메서드 + private ResponseCookie buildCookie(String name, String value, long maxAgeSeconds) { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(authCookieProperties.isSecure()) + .sameSite(authCookieProperties.getSameSite()) + .path(authCookieProperties.getPath()) + .maxAge(maxAgeSeconds) + .build(); + } private String extractCookie(HttpServletRequest request, String name) { Cookie[] cookies = request.getCookies(); diff --git a/src/main/java/com/server/user/domain/PasswordResetToken.java b/src/main/java/com/server/auth/domain/PasswordResetToken.java similarity index 96% rename from src/main/java/com/server/user/domain/PasswordResetToken.java rename to src/main/java/com/server/auth/domain/PasswordResetToken.java index b65cd178..64c0fc6b 100644 --- a/src/main/java/com/server/user/domain/PasswordResetToken.java +++ b/src/main/java/com/server/auth/domain/PasswordResetToken.java @@ -1,6 +1,7 @@ -package com.server.user.domain; +package com.server.auth.domain; import com.server.global.entity.BaseEntity; +import com.server.user.domain.User; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; diff --git a/src/main/java/com/server/auth/repository/PasswordResetTokenRepository.java b/src/main/java/com/server/auth/repository/PasswordResetTokenRepository.java index 8b8356de..1f3fb679 100644 --- a/src/main/java/com/server/auth/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/server/auth/repository/PasswordResetTokenRepository.java @@ -1,14 +1,11 @@ package com.server.auth.repository; -import com.server.user.domain.PasswordResetToken; -import org.springframework.data.domain.Pageable; +import com.server.auth.domain.PasswordResetToken; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; // 비밀번호 재설정 토큰 저장소 diff --git a/src/main/java/com/server/auth/service/PasswordResetService.java b/src/main/java/com/server/auth/service/PasswordResetService.java index 3b9bf113..03415f10 100644 --- a/src/main/java/com/server/auth/service/PasswordResetService.java +++ b/src/main/java/com/server/auth/service/PasswordResetService.java @@ -6,7 +6,7 @@ import com.server.auth.exception.AuthErrorCase; import com.server.auth.repository.PasswordResetTokenRepository; import com.server.global.exception.ApplicationException; -import com.server.user.domain.PasswordResetToken; +import com.server.auth.domain.PasswordResetToken; import com.server.user.domain.User; import com.server.user.repository.UserRepository; import com.server.user.util.PasswordValidator; @@ -15,7 +15,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; -import org.springframework.data.domain.PageRequest; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.security.crypto.password.PasswordEncoder; @@ -29,7 +28,6 @@ import java.time.LocalDateTime; import java.time.Year; import java.util.Base64; -import java.util.List; @Slf4j @Service diff --git a/src/test/java/com/server/auth/controller/AuthControllerTest.java b/src/test/java/com/server/auth/controller/AuthControllerTest.java index 72446450..36ce6ef3 100644 --- a/src/test/java/com/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/server/auth/controller/AuthControllerTest.java @@ -1,6 +1,7 @@ package com.server.auth.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import com.server.auth.config.AuthCookieProperties; import com.server.auth.dto.PasswordResetConfirmRequestDto; import com.server.auth.dto.PasswordResetRequestDto; import com.server.auth.dto.PasswordResetResponseDto; @@ -9,22 +10,26 @@ import com.server.auth.service.PasswordResetService; import com.server.user.dto.UserLoginRequestDto; import com.server.user.dto.UserLoginResponseDto; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(AuthController.class) @AutoConfigureMockMvc(addFilters = false) @@ -42,6 +47,20 @@ class AuthControllerTest { @MockitoBean PasswordResetService passwordResetService; + @MockitoBean + AuthCookieProperties authCookieProperties; + + @BeforeEach + void setUp() { + //AuthCookieProperties mock 값 세팅 + when(authCookieProperties.isSecure()).thenReturn(false); + when(authCookieProperties.getSameSite()).thenReturn("Lax"); + when(authCookieProperties.getPath()).thenReturn("/"); + when(authCookieProperties.getAccessMaxAgeSeconds()).thenReturn(3600L); + when(authCookieProperties.getRefreshMaxAgeSeconds()).thenReturn(604800L); + + } + @Test @DisplayName("로그인 성공") void login_success() throws Exception { @@ -65,7 +84,8 @@ void login_success() throws Exception { ) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.accessToken").value("access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")); + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + hasItem(containsString("refreshToken=")))); } @Test @@ -83,12 +103,14 @@ void refresh_success() throws Exception { mockMvc.perform( post("/api/v1/auth/token/refresh") + .cookie(new Cookie("refreshToken", "old-refresh-token")) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto)) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")); + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + hasItem(containsString("refreshToken=")))); } @Test diff --git a/src/test/java/com/server/auth/service/PasswordResetServiceTest.java b/src/test/java/com/server/auth/service/PasswordResetServiceTest.java index fd4f05cd..ec55dc0d 100644 --- a/src/test/java/com/server/auth/service/PasswordResetServiceTest.java +++ b/src/test/java/com/server/auth/service/PasswordResetServiceTest.java @@ -6,7 +6,7 @@ import com.server.auth.exception.AuthErrorCase; import com.server.auth.repository.PasswordResetTokenRepository; import com.server.global.exception.ApplicationException; -import com.server.user.domain.PasswordResetToken; +import com.server.auth.domain.PasswordResetToken; import com.server.user.domain.User; import com.server.user.repository.UserRepository; import com.server.user.util.PasswordValidator;