Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
@@ -0,0 +1,46 @@
package com.tavemakers.surf.domain.login.controller;

import com.tavemakers.surf.domain.login.auth.service.RefreshTokenService;
import com.tavemakers.surf.global.common.response.ApiResponse;
import com.tavemakers.surf.global.jwt.JwtService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "인증", description = "로그인/로그아웃")
@RestController
@RequiredArgsConstructor
public class LogoutController {

private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;

@Operation(summary = "로그아웃", description = "현재 디바이스의 refreshToken을 무효화하고 쿠키를 삭제합니다.")
@PostMapping("/auth/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) {

jwtService.extractRefreshToken(request).ifPresent(refreshToken -> {
if (jwtService.isTokenValid(refreshToken)) {
Long memberId = jwtService.extractMemberId(refreshToken).orElse(null);
String deviceId = jwtService.extractDeviceId(refreshToken).orElse(null);
if (memberId != null && deviceId != null) {
refreshTokenService.invalidate(memberId, deviceId);
}
}
});

// 쿠키 삭제는 항상 수행
jwtService.clearRefreshTokenCookie(response);

// 컨텍스트 정리
SecurityContextHolder.clearContext();

return ApiResponse.response(HttpStatus.NO_CONTENT, "로그아웃 완료", null);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/tavemakers/surf/domain/member/entity/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import lombok.NoArgsConstructor;
import lombok.Builder;
import com.tavemakers.surf.domain.member.entity.enums.Part;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand All @@ -22,12 +24,14 @@

import lombok.extern.slf4j.Slf4j;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.Where;


@Entity
@Getter
@Slf4j
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자 protected 설정
@Where(clause = "is_deleted = false")
public class Member extends BaseEntity {

@Id
Expand Down Expand Up @@ -76,6 +80,11 @@ public class Member extends BaseEntity {

private boolean activityStatus; // 활동/비활동 여부

@Column(name = "is_deleted", nullable = false)
private boolean isDeleted = false;

private LocalDateTime deletedAt;

public boolean isYB() {
return memberType == MemberType.YB;
}
Expand Down Expand Up @@ -245,5 +254,13 @@ private <T> void updateIfNotNull(T value, Consumer<T> updater) {
}
}


// 회원 탈퇴 처리
public void withdraw() {
this.isDeleted = true;
this.deletedAt = LocalDateTime.now();
this.activityStatus = false;
this.status = MemberStatus.WITHDRAWN;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum MemberStatus {
REGISTERING, // 가입중
WAITING, // 대기중
APPROVED, // 승인
REJECTED // 거절됨
REJECTED, // 거절됨
WITHDRAWN // 탈퇴됨
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.tavemakers.surf.global.config;

import com.tavemakers.surf.domain.member.repository.MemberRepository;
import com.tavemakers.surf.domain.member.service.CustomUserDetailsService;
import com.tavemakers.surf.global.jwt.JwtAuthenticationFilter;
import com.tavemakers.surf.global.jwt.JwtService;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -58,6 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/**").permitAll() // 로그인
.requestMatchers("/auth/refresh").permitAll() //
.requestMatchers("/auth/logout").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
.requestMatchers(permitUrlConfig.getPublicUrl()).permitAll()
.requestMatchers(permitUrlConfig.getMemberUrl()).hasAnyRole("MEMBER", "ADMIN", "PRESIDENT", "MANAGER")
Expand Down Expand Up @@ -98,7 +98,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration c

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtService, memberRepository, redisTemplate);
return new JwtAuthenticationFilter(jwtService, memberRepository);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
Expand All @@ -23,91 +22,62 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtService jwtService;
private final MemberRepository memberRepository;
private final RedisTemplate<String, String> redisTemplate; // 없으면 null 주입 가능

private static final String LOGOUT_URL = "/auth/logout";

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.startsWith("/login/")
|| uri.equals(LOGOUT_URL);
|| uri.equals("/auth/logout")
|| uri.equals("/auth/refresh")
|| uri.startsWith("/swagger-ui")
|| uri.startsWith("/v3/api-docs");
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {

final String uri = request.getRequestURI();

String refreshToken = jwtService.extractRefreshToken(request).orElse(null);
String accessToken = jwtService.extractAccessTokenFromHeader(request)
.filter(jwtService::isTokenValid)
.orElse(null);

log.debug("URI: {}, accessToken? {}, refreshToken? {}", uri, accessToken != null, refreshToken != null);
String accessToken = jwtService.extractAccessTokenFromHeader(request).orElse(null);

// 1) 토큰이 아예 없는 경우 → 그냥 통과 (카카오 콜백 포함)
if (accessToken == null && refreshToken == null) {
// 1) AT가 아예 없으면 → 익명 통과 (인가 여부는 SecurityConfig가 판단)
if (accessToken == null) {
chain.doFilter(request, response);
return;
}

// 2) 둘 다 있는 경우: 액세스 토큰 블랙리스트만 체크하고 통과
if (accessToken != null && refreshToken != null) {
log.info("토큰 둘다 있는 경우");
if (isBlacklisted(accessToken)) {
unauthorized(response);
return;
}
authenticateUser(accessToken, request);
chain.doFilter(request, response);
// 2) AT가 있는데 유효하지 않으면 → 401 (프론트가 /auth/refresh 후 재시도)
if (!jwtService.isTokenValid(accessToken)) {
unauthorized(response, "Invalid or expired access token");
return;
}

// 3) 액세스만 있는 경우: 블랙리스트 체크 후 인증 주입
if (accessToken != null) {
log.info("Access만 있는 경우");
if (isBlacklisted(accessToken)) {
unauthorized(response);
return;
}
authenticateUser(accessToken, request);
chain.doFilter(request, response);
return;
}

// 4) 액세스 없음 + 리프레시만 있는 경우: 재발급 후 401로 재시도 유도
log.info("refresh만 있는 경우");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"Access token이 필요합니다. /auth/refresh로 재발급하세요.\"}");
// 3) 유효하면 인증 주입 후 통과
authenticateUser(accessToken, request);
chain.doFilter(request, response);
}

/** AccessToken → memberId → Member 로드 → SecurityContext 주입 */
private void authenticateUser(String accessToken, HttpServletRequest req) {
jwtService.extractMemberId(accessToken).flatMap(memberRepository::findById).ifPresent(member -> {
CustomUserDetails principal = new CustomUserDetails(member);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities());
auth
.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
jwtService.extractMemberId(accessToken)
.flatMap(memberRepository::findById)
.ifPresent(member -> {
CustomUserDetails principal = new CustomUserDetails(member);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
});
}
Comment on lines 65 to 79
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

회원 조회 실패 시 silent failure가 발생할 수 있습니다.

memberRepository.findById()가 빈 결과를 반환하면(탈퇴한 사용자 등) ifPresent로 인해 아무 동작 없이 넘어갑니다. 유효한 AT를 가진 요청이 익명으로 처리되어 의도치 않은 동작을 유발할 수 있습니다.

원인: @Where(clause = "is_deleted = false")가 적용된 Member 엔티티에서 soft-delete된 사용자는 조회되지 않습니다.

권장 개선안:

  1. 회원이 없을 때 401 응답 반환
  2. 최소한 디버깅을 위한 로그 추가
🛠️ 권장 수정 코드
     /** AccessToken → memberId → Member 로드 → SecurityContext 주입 */
-    private void authenticateUser(String accessToken, HttpServletRequest req) {
-        jwtService.extractMemberId(accessToken)
-                .flatMap(memberRepository::findById)
-                .ifPresent(member -> {
-                    CustomUserDetails principal = new CustomUserDetails(member);
-                    UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
-                            principal, null, principal.getAuthorities());
-                    auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
-
-                    SecurityContext context = SecurityContextHolder.createEmptyContext();
-                    context.setAuthentication(auth);
-                    SecurityContextHolder.setContext(context);
-        });
-    }
+    private boolean authenticateUser(String accessToken, HttpServletRequest req) {
+        return jwtService.extractMemberId(accessToken)
+                .flatMap(memberRepository::findById)
+                .map(member -> {
+                    CustomUserDetails principal = new CustomUserDetails(member);
+                    UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+                            principal, null, principal.getAuthorities());
+                    auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
+
+                    SecurityContext context = SecurityContextHolder.createEmptyContext();
+                    context.setAuthentication(auth);
+                    SecurityContextHolder.setContext(context);
+                    return true;
+                })
+                .orElseGet(() -> {
+                    log.warn("Member not found for valid token");
+                    return false;
+                });
+    }

doFilterInternal에서 반환값 처리:

-        // 3) 유효하면 인증 주입 후 통과
-        authenticateUser(accessToken, request);
-        chain.doFilter(request, response);
+        // 3) 유효하면 인증 주입 후 통과
+        if (!authenticateUser(accessToken, request)) {
+            unauthorized(response, "Member not found");
+            return;
+        }
+        chain.doFilter(request, response);

Spring Security 공식 문서의 Authentication Architecture에서 인증 실패 처리 패턴을 참고하실 수 있습니다.

Committable suggestion skipped: line range outside the PR's diff.


private boolean isBlacklisted(String accessToken) {
if (redisTemplate == null) return false;
return redisTemplate.opsForValue().get("blacklist:" + accessToken) != null;
}

private void unauthorized(HttpServletResponse res) throws IOException {
private void unauthorized(HttpServletResponse res, String message) throws IOException {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.getWriter().write("This access token is blacklisted (logged out).");
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"message\":\"" + message + "\"}");
}
}
2 changes: 2 additions & 0 deletions src/main/java/com/tavemakers/surf/global/jwt/JwtService.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ public interface JwtService {

void sendRefreshToken(HttpServletResponse res, String refreshToken);
Optional<String> extractDeviceId(String refreshToken);

void clearRefreshTokenCookie(HttpServletResponse response);
}
27 changes: 26 additions & 1 deletion src/main/java/com/tavemakers/surf/global/jwt/JwtServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public void sendRefreshToken(
ResponseCookie.ResponseCookieBuilder builder =
ResponseCookie.from(REFRESH_COOKIE_NAME, refreshToken)
.httpOnly(true)
.path("/auth/refresh")
.path("/")
.maxAge(Duration.ofMillis(refreshTokenExpireMs));

if (isDev) {
Expand Down Expand Up @@ -190,4 +190,29 @@ public Optional<String> extractDeviceId(String token) {
return Optional.empty();
}
}

@Override
public void clearRefreshTokenCookie(HttpServletResponse res) {
boolean isDev = "dev".equalsIgnoreCase(activeProfile);

ResponseCookie.ResponseCookieBuilder builder =
ResponseCookie.from(REFRESH_COOKIE_NAME, "")
.httpOnly(true)
.path("/") // 발급할 때랑 반드시 동일
.maxAge(Duration.ZERO); // 즉시 만료

if (isDev) {
builder
.secure(true)
.domain(".tavesurf.site")
.sameSite("None");
} else {
builder
.secure(false)
.sameSite("Lax");
}

ResponseCookie refreshCookie = builder.build();
res.addHeader("Set-Cookie", refreshCookie.toString());
}
}