Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -14,13 +14,15 @@
import hanium.modic.backend.common.property.property.TokenProperty;
import hanium.modic.backend.common.security.principal.AuthenticatedUser;
import hanium.modic.backend.common.security.principal.UserPrincipal;
import hanium.modic.backend.domain.auth.constant.AuthConstant;
import hanium.modic.backend.domain.auth.dto.Token;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.repository.UserEntityRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

Expand Down Expand Up @@ -95,6 +97,12 @@ public void validateToken(final String accessToken) {
}
}

public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(AuthConstant.AUTHORIZATION)).filter(
accessToken -> accessToken.startsWith(AuthConstant.BEARER)
).map(accessToken -> accessToken.replace(AuthConstant.BEARER, ""));
}

public String getType(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(tokenProperty.getSecretKey().getBytes(StandardCharsets.UTF_8)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final CookieUtil cookieUtil;

@Override
@Transactional
Expand All @@ -44,7 +45,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo

// 엑세스 토큰과 리프레시 토큰을 응답 헤더와 쿠키에 설정
response.addHeader(AuthConstant.AUTHORIZATION, AuthConstant.BEARER + token.accessToken());
Cookie refreshTokenCookie = CookieUtil.createRefreshCookie(token.refreshToken());
Cookie refreshTokenCookie = cookieUtil.createRefreshCookie(token.refreshToken());
response.addCookie(refreshTokenCookie);

// Todo: 리다이렉트 URL을 환경 변수로 관리
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ public LoginResponse login(final String email, final String password) {
return LoginResponse.from(token);
}

public void logout(final String refreshToken, final String accessToken) {
jwtTokenProvider.setBlackList(refreshToken);
jwtTokenProvider.setBlackList(accessToken);

UserEntity user = jwtTokenProvider.getUser(refreshToken)
.orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND_EXCEPTION));

refreshTokenRepository.deleteById(user.getId());
}

public ReissueResponse reissue(final String refreshToken) {
if (blackListRepository.existsById(refreshToken)) {
throw new AppException(ErrorCode.TOKEN_BLACKLISTED_EXCEPTION);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,8 @@

import jakarta.servlet.http.Cookie;

public class CookieUtil {
public interface CookieUtil {
Cookie createRefreshCookie(final String refreshToken);

private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";

private static final int COOKIE_MAX_AGE = 60 * 60 * 24 * 3;

public static Cookie createRefreshCookie(final String refreshToken) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
// cookie.setSecure(true); // Todo: https://github.com/Modic-2025/modic_backend/issues/39
cookie.setMaxAge(COOKIE_MAX_AGE);
return cookie;
}
Cookie deleteRefreshCookie();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package hanium.modic.backend.domain.auth.util;

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.Cookie;

// 개발 환경용 쿠키 유틸리티
@Component
@Profile({"local", "dev", "test"})
public class DevCookieUtil implements CookieUtil {

private final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";

private final int COOKIE_MAX_AGE = 60 * 60 * 24 * 3;

public Cookie createRefreshCookie(final String refreshToken) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(COOKIE_MAX_AGE);
return cookie;
}

public Cookie deleteRefreshCookie() {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, null);
cookie.setHttpOnly(true);
cookie.setPath("/"); // 생성 시와 동일해야 함
cookie.setMaxAge(0); // 브라우저에서 즉시 삭제
return cookie;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package hanium.modic.backend.domain.auth.util;

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.Cookie;

// Production 환경용 쿠키 유틸리티
@Component
@Profile({"main"})
public class ProdCookieUtil implements CookieUtil {

private final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";

private final int COOKIE_MAX_AGE = 60 * 60 * 24 * 3;

public Cookie createRefreshCookie(final String refreshToken) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setSecure(true);
cookie.setMaxAge(COOKIE_MAX_AGE);
return cookie;
}

public Cookie deleteRefreshCookie() {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, null);
cookie.setHttpOnly(true);
cookie.setPath("/"); // 생성 시와 동일해야 함
cookie.setSecure(true);
cookie.setMaxAge(0); // 브라우저에서 즉시 삭제
return cookie;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,10 @@ public void updateEmail(String email) {
}
this.email = email;
}

public void softWithdraw() {
this.userRole = UserRole.WITHDRAWN;
this.email = "withdrawn_" + this.id + "_" + java.util.UUID.randomUUID().toString() + "@modic.com";
this.name = "탈퇴회원";
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package hanium.modic.backend.domain.user.enums;

public enum UserRole {
ADMIN, USER
ADMIN,
USER,
WITHDRAWN
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ private void checkDuplicateEmail(final String email) {
}
}

// 회원탈퇴
@Transactional
public void deleteUser(final long id) {
UserEntity user = userEntityRepository.findById(id)
.orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND_EXCEPTION));
user.softWithdraw();

userEntityRepository.save(user);
}

// 회원 정보 조회
public UserInfoResponse getUserInfo(UserEntity user) {
Optional<String> userImageUrl = userImageService.createImageGetUrlOptional(user.getId());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package hanium.modic.backend.web.auth.controller;

import static hanium.modic.backend.common.error.ErrorCode.*;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CookieValue;
Expand All @@ -10,7 +12,9 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import hanium.modic.backend.common.jwt.JwtTokenProvider;
import hanium.modic.backend.common.response.AppResponse;
import hanium.modic.backend.common.swagger.ApiErrorMapping;
import hanium.modic.backend.domain.auth.constant.AuthConstant;
import hanium.modic.backend.domain.auth.service.AuthService;
import hanium.modic.backend.domain.auth.util.CookieUtil;
Expand All @@ -24,6 +28,7 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
Expand All @@ -37,6 +42,8 @@
public class AuthController {

private final AuthService authService;
private final CookieUtil cookieUtil;
private final JwtTokenProvider jwtTokenProvider;

@PostMapping("/login")
@Operation(
Expand All @@ -52,12 +59,34 @@ public ResponseEntity<AppResponse<LoginResponse>> login(@RequestBody @Valid Logi
LoginResponse loginResponse = authService.login(request.email(), request.password());

response.addHeader(AuthConstant.AUTHORIZATION, AuthConstant.BEARER + loginResponse.accessToken());
Cookie refreshTokenCookie = CookieUtil.createRefreshCookie(loginResponse.refreshToken());
Cookie refreshTokenCookie = cookieUtil.createRefreshCookie(loginResponse.refreshToken());
response.addCookie(refreshTokenCookie);

return ResponseEntity.ok(AppResponse.ok(loginResponse));
}

@PostMapping("/logout")
@Operation(
summary = "로그아웃 API",
description = "리프레시 토큰을 통해 로그아웃합니다. 로그아웃된 토큰으로 요청시 [C-005] - 차단된 토큰입니다를 반환합니다."
)
@ApiErrorMapping({
USER_NOT_FOUND_EXCEPTION
})
public ResponseEntity<AppResponse<Void>> logout(
@CookieValue(name = "refreshToken") String refreshToken,
HttpServletRequest request,
HttpServletResponse response
) {
String accessToken = jwtTokenProvider.extractAccessToken(request).orElse(null);
authService.logout(refreshToken, accessToken);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

AccessToken Optional을 null로 넘기지 마세요
Authorization 헤더가 비어 있으면 accessToken이 null이 되어 authService.logout에서 jwtTokenProvider.setBlackList(null)을 호출하게 되고, 유효하지 않은 키로 블랙리스트에 저장하거나 NPE가 발생합니다. 헤더가 없으면 즉시 인증 예외를 던져 주세요.

+import hanium.modic.backend.common.error.exception.AppException;
...
-		String accessToken = jwtTokenProvider.extractAccessToken(request).orElse(null);
+		String accessToken = jwtTokenProvider.extractAccessToken(request)
+			.orElseThrow(() -> new AppException(USER_NOT_AUTHENTICATED_EXCEPTION));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String accessToken = jwtTokenProvider.extractAccessToken(request).orElse(null);
authService.logout(refreshToken, accessToken);
String accessToken = jwtTokenProvider.extractAccessToken(request)
.orElseThrow(() -> new AppException(USER_NOT_AUTHENTICATED_EXCEPTION));
authService.logout(refreshToken, accessToken);
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/web/auth/controller/AuthController.java
around lines 85 to 87, the code extracts the access token as an Optional and
passes null to authService.logout when the Authorization header is missing;
instead fail fast: if the access token Optional is empty, throw an
authentication/authorization exception (e.g., BadCredentialsException or a
custom AuthException) immediately rather than passing null, otherwise call
authService.logout with the non-null access token; ensure
jwtTokenProvider.setBlackList is never called with null by enforcing the
presence of the token before calling logout.

Cookie deleteRefreshTokenCookie = cookieUtil.deleteRefreshCookie();
response.addCookie(deleteRefreshTokenCookie);

return ResponseEntity.ok().build();
}

@PostMapping("/reissue")
@Operation(
summary = "토큰 재발급 API",
Expand All @@ -73,7 +102,7 @@ public ResponseEntity<AppResponse<Void>> reissue(@CookieValue(name = "refreshTok
ReissueResponse reissueResponse = authService.reissue(refreshToken);

response.addHeader(AuthConstant.AUTHORIZATION, AuthConstant.BEARER + reissueResponse.accessToken());
Cookie refreshTokenCookie = CookieUtil.createRefreshCookie(reissueResponse.refreshToken());
Cookie refreshTokenCookie = cookieUtil.createRefreshCookie(reissueResponse.refreshToken());
response.addCookie(refreshTokenCookie);

return ResponseEntity.ok().build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
Expand Down Expand Up @@ -63,6 +64,16 @@ public ResponseEntity<AppResponse<UserCreateResponse>> createUser(@RequestBody @
)));
}

@DeleteMapping
@Operation(
summary = "회원탈퇴 API",
description = "로그인한 유저의 계정을 삭제합니다."
)
public ResponseEntity<AppResponse<Void>> deleteUser(@CurrentUser UserEntity user) {
userService.deleteUser(user.getId());
return ResponseEntity.ok().build();
}

@GetMapping("/me")
@Operation(
summary = "유저 정보 조회 API",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.test.context.ActiveProfiles;

import hanium.modic.backend.common.jwt.JwtTokenProvider;
import hanium.modic.backend.common.jwt.RefreshToken;
import hanium.modic.backend.common.jwt.RefreshTokenRepository;
import hanium.modic.backend.domain.auth.constant.AuthConstant;
import hanium.modic.backend.domain.auth.dto.Token;
import hanium.modic.backend.domain.auth.util.CookieUtil;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.factory.UserFactory;
import jakarta.servlet.http.Cookie;

@ActiveProfiles("test")
@ExtendWith(MockitoExtension.class)
class OAuthSuccessHandlerTest {

Expand All @@ -44,6 +47,9 @@ class OAuthSuccessHandlerTest {
@Mock
private CustomOAuth2User customOAuth2User;

@Mock
private CookieUtil cookieUtil;

private MockHttpServletRequest request;
private MockHttpServletResponse response;
private UserEntity mockUser;
Expand All @@ -64,6 +70,8 @@ void onAuthenticationSuccess_success() throws IOException {
when(authentication.getPrincipal()).thenReturn(customOAuth2User);
when(customOAuth2User.getUserEntity()).thenReturn(mockUser);
when(jwtTokenProvider.createToken(customOAuth2User)).thenReturn(mockToken);
when(cookieUtil.createRefreshCookie(mockToken.refreshToken()))
.thenReturn(new Cookie("refreshToken", mockToken.refreshToken()));

// when
oAuthSuccessHandler.onAuthenticationSuccess(request, response, authentication);
Expand All @@ -88,6 +96,8 @@ void onAuthenticationSuccess_setsAccessTokenInHeader() throws IOException {
when(authentication.getPrincipal()).thenReturn(customOAuth2User);
when(customOAuth2User.getUserEntity()).thenReturn(mockUser);
when(jwtTokenProvider.createToken(customOAuth2User)).thenReturn(mockToken);
when(cookieUtil.createRefreshCookie(mockToken.refreshToken()))
.thenReturn(new Cookie("refreshToken", mockToken.refreshToken()));

// when
oAuthSuccessHandler.onAuthenticationSuccess(request, response, authentication);
Expand All @@ -104,6 +114,8 @@ void onAuthenticationSuccess_setsRefreshTokenInCookie() throws IOException {
when(authentication.getPrincipal()).thenReturn(customOAuth2User);
when(customOAuth2User.getUserEntity()).thenReturn(mockUser);
when(jwtTokenProvider.createToken(customOAuth2User)).thenReturn(mockToken);
when(cookieUtil.createRefreshCookie(mockToken.refreshToken()))
.thenReturn(new Cookie("refreshToken", mockToken.refreshToken()));

// when
oAuthSuccessHandler.onAuthenticationSuccess(request, response, authentication);
Expand All @@ -123,6 +135,8 @@ void onAuthenticationSuccess_redirectsToCorrectUrl() throws IOException {
when(authentication.getPrincipal()).thenReturn(customOAuth2User);
when(customOAuth2User.getUserEntity()).thenReturn(mockUser);
when(jwtTokenProvider.createToken(customOAuth2User)).thenReturn(mockToken);
when(cookieUtil.createRefreshCookie(mockToken.refreshToken()))
.thenReturn(new Cookie("refreshToken", mockToken.refreshToken()));

// when
oAuthSuccessHandler.onAuthenticationSuccess(request, response, authentication);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public class AuthControllerIntegrationTest extends BaseIntegrationTest {
@Autowired
private AuthCodeRepository authCodeRepository;

@Autowired
private CookieUtil cookieUtil;

@Test
@DisplayName("로그인 API 테스트")
void loginApiSuccessTest() throws Exception {
Expand Down Expand Up @@ -77,7 +80,7 @@ void reissueApiSuccess() throws Exception {
.build();
refreshTokenRepository.save(refreshToken);

Cookie refreshCookie = CookieUtil.createRefreshCookie(token.refreshToken());
Cookie refreshCookie = cookieUtil.createRefreshCookie(token.refreshToken());

// when, then
mockMvc.perform(post("/api/auth/reissue")
Expand Down
Loading