diff --git a/src/main/java/com/tavemakers/surf/domain/login/controller/LogoutController.java b/src/main/java/com/tavemakers/surf/domain/login/controller/LogoutController.java new file mode 100644 index 00000000..b32ac0c8 --- /dev/null +++ b/src/main/java/com/tavemakers/surf/domain/login/controller/LogoutController.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/tavemakers/surf/domain/member/entity/Member.java b/src/main/java/com/tavemakers/surf/domain/member/entity/Member.java index 3261ff1b..9adeb5a4 100644 --- a/src/main/java/com/tavemakers/surf/domain/member/entity/Member.java +++ b/src/main/java/com/tavemakers/surf/domain/member/entity/Member.java @@ -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; @@ -22,6 +24,7 @@ import lombok.extern.slf4j.Slf4j; import org.hibernate.annotations.BatchSize; +import org.hibernate.annotations.Where; @Entity @@ -76,6 +79,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; } @@ -245,5 +253,13 @@ private void updateIfNotNull(T value, Consumer updater) { } } + + // 회원 탈퇴 처리 + public void withdraw() { + this.isDeleted = true; + this.deletedAt = LocalDateTime.now(); + this.activityStatus = false; + this.status = MemberStatus.WITHDRAWN; + } } diff --git a/src/main/java/com/tavemakers/surf/domain/member/entity/enums/MemberStatus.java b/src/main/java/com/tavemakers/surf/domain/member/entity/enums/MemberStatus.java index 01fd459c..58445fa7 100644 --- a/src/main/java/com/tavemakers/surf/domain/member/entity/enums/MemberStatus.java +++ b/src/main/java/com/tavemakers/surf/domain/member/entity/enums/MemberStatus.java @@ -4,5 +4,6 @@ public enum MemberStatus { REGISTERING, // 가입중 WAITING, // 대기중 APPROVED, // 승인 - REJECTED // 거절됨 + REJECTED, // 거절됨 + WITHDRAWN // 탈퇴됨 } diff --git a/src/main/java/com/tavemakers/surf/global/config/SecurityConfig.java b/src/main/java/com/tavemakers/surf/global/config/SecurityConfig.java index 028ca594..6c33a08f 100644 --- a/src/main/java/com/tavemakers/surf/global/config/SecurityConfig.java +++ b/src/main/java/com/tavemakers/surf/global/config/SecurityConfig.java @@ -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; @@ -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") @@ -98,7 +98,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration c @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { - return new JwtAuthenticationFilter(jwtService, memberRepository, redisTemplate); + return new JwtAuthenticationFilter(jwtService, memberRepository); } } \ No newline at end of file diff --git a/src/main/java/com/tavemakers/surf/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/tavemakers/surf/global/jwt/JwtAuthenticationFilter.java index 84462645..754c1b91 100644 --- a/src/main/java/com/tavemakers/surf/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/tavemakers/surf/global/jwt/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.tavemakers.surf.global.jwt; +import com.fasterxml.jackson.databind.ObjectMapper; import com.tavemakers.surf.domain.member.entity.CustomUserDetails; import com.tavemakers.surf.domain.member.repository.MemberRepository; import jakarta.servlet.FilterChain; @@ -8,7 +9,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; @@ -16,6 +16,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Map; @RequiredArgsConstructor @Slf4j @@ -23,103 +24,63 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final MemberRepository memberRepository; - private final RedisTemplate redisTemplate; // 없으면 null 주입 가능 - - private static final String LOGOUT_URL = "/auth/logout"; + private final ObjectMapper objectMapper = new ObjectMapper(); @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(); - - log.info("[SECURITY][FILTER] ENTER uri={} method={}", uri, request.getMethod()); - - String refreshToken = jwtService.extractRefreshToken(request).orElse(null); - String accessToken = jwtService.extractAccessTokenFromHeader(request) - .filter(jwtService::isTokenValid) - .orElse(null); - - log.info("URI: {}, accessToken? {}, refreshToken? {}", uri, accessToken != null, refreshToken != null); - - // 1) 토큰이 아예 없는 경우 → 그냥 통과 (카카오 콜백 포함) - if (accessToken == null && refreshToken == null) { - log.info("[SECURITY][FILTER] no tokens → pass"); - chain.doFilter(request, response); - return; - } + String accessToken = jwtService.extractAccessTokenFromHeader(request).orElse(null); - // 2) 둘 다 있는 경우: 액세스 토큰 블랙리스트만 체크하고 통과 - if (accessToken != null && refreshToken != null) { - log.info("[SECURITY][FILTER] access + refresh"); - if (isBlacklisted(accessToken)) { - unauthorized(response); - return; - } - authenticateUser(accessToken, request); + // 1) AT가 아예 없으면 → 익명 통과 (인가 여부는 SecurityConfig가 판단) + if (accessToken == null) { chain.doFilter(request, response); return; } - // 3) 액세스만 있는 경우: 블랙리스트 체크 후 인증 주입 - if (accessToken != null) { - log.info("[SECURITY][FILTER] access token only"); - - 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; } - // 4) 액세스 없음 + 리프레시만 있는 경우: 재발급 후 401로 재시도 유도 - log.info("[SECURITY][FILTER] refresh only → 401"); - 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) { - - log.info("[SECURITY][AUTH] start authenticate"); - jwtService.extractMemberId(accessToken) .flatMap(memberRepository::findById) .ifPresent(member -> { - log.info("[SECURITY][AUTH] member found id={}", member.getId()); - - 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); - log.info("[SECURITY][AUTH] SecurityContext set"); + 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 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(objectMapper.writeValueAsString(Map.of("message", message))); } } \ No newline at end of file diff --git a/src/main/java/com/tavemakers/surf/global/jwt/JwtService.java b/src/main/java/com/tavemakers/surf/global/jwt/JwtService.java index ac3f7074..08a31f76 100644 --- a/src/main/java/com/tavemakers/surf/global/jwt/JwtService.java +++ b/src/main/java/com/tavemakers/surf/global/jwt/JwtService.java @@ -20,4 +20,6 @@ public interface JwtService { void sendRefreshToken(HttpServletResponse res, String refreshToken); Optional extractDeviceId(String refreshToken); + + void clearRefreshTokenCookie(HttpServletResponse response); } diff --git a/src/main/java/com/tavemakers/surf/global/jwt/JwtServiceImpl.java b/src/main/java/com/tavemakers/surf/global/jwt/JwtServiceImpl.java index 44cfabab..987108cd 100644 --- a/src/main/java/com/tavemakers/surf/global/jwt/JwtServiceImpl.java +++ b/src/main/java/com/tavemakers/surf/global/jwt/JwtServiceImpl.java @@ -151,7 +151,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()) { @@ -208,4 +208,27 @@ public Optional extractDeviceId(String token) { return Optional.empty(); } } + + @Override + public void clearRefreshTokenCookie(HttpServletResponse res) { + 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 if (isTest()) { + builder + .secure(false) + .sameSite("Lax"); + } + + ResponseCookie refreshCookie = builder.build(); + res.addHeader("Set-Cookie", refreshCookie.toString()); + } }