diff --git a/cookies.txt b/cookies.txt deleted file mode 100644 index c31d9899..00000000 --- a/cookies.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - diff --git a/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java b/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java index 6c4ecff3..523eda4e 100644 --- a/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java +++ b/src/main/java/com/back/domain/myhistory/dto/MyHistoryPostItemDto.java @@ -2,33 +2,49 @@ import com.back.domain.post.post.entity.Post; import com.back.domain.post.post.entity.PostImage; -import java.util.List; +import com.back.domain.post.post.enums.PostStatus; import lombok.Builder; import lombok.Getter; import java.time.LocalDateTime; +import java.util.List; @Getter @Builder public class MyHistoryPostItemDto { private Long id; + private Long postId; + private String categoryName; + private String userNickName; private String title; + private String content; private List imageUrls; private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private PostStatus status; private Integer likeCount; private Integer commentCount; + private Integer viewCount; public static MyHistoryPostItemDto from(Post p) { + String categoryName = p.getCategory() != null ? p.getCategory().getName() : null; + String userNickName = p.getUser() != null ? p.getUser().getNickname() : null; return MyHistoryPostItemDto.builder() .id(p.getId()) + .postId(p.getId()) + .categoryName(categoryName) + .userNickName(userNickName) .title(p.getTitle()) + .content(p.getContent()) .imageUrls(p.getImages().stream() - .map(PostImage::getUrl) - .toList()) + .map(PostImage::getUrl) + .toList()) .createdAt(p.getCreatedAt()) + .updatedAt(p.getUpdatedAt()) + .status(p.getStatus()) .likeCount(p.getLikeCount()) .commentCount(p.getCommentCount()) + .viewCount(p.getViewCount()) .build(); } } - diff --git a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java index f141c1ee..855788aa 100644 --- a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java @@ -2,10 +2,11 @@ import com.back.domain.post.comment.entity.Comment; import com.back.domain.post.comment.enums.CommentStatus; -import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface CommentRepository extends JpaRepository { diff --git a/src/main/java/com/back/domain/post/post/controller/PostController.java b/src/main/java/com/back/domain/post/post/controller/PostController.java index ae763845..2198d147 100644 --- a/src/main/java/com/back/domain/post/post/controller/PostController.java +++ b/src/main/java/com/back/domain/post/post/controller/PostController.java @@ -3,6 +3,7 @@ import com.back.domain.post.post.dto.request.PostCreateRequestDto; import com.back.domain.post.post.dto.request.PostSortScrollRequestDto; import com.back.domain.post.post.dto.request.PostUpdateRequestDto; +import com.back.domain.post.post.dto.response.PostLikeResponseDto; import com.back.domain.post.post.dto.response.PostResponseDto; import com.back.domain.post.post.service.PostService; import com.back.global.rsData.RsData; @@ -114,10 +115,9 @@ public RsData deletePost( */ @PostMapping("/{postId}/like") @Operation(summary = "게시글 추천") - public RsData toggleLike( + public RsData toggleLike( @PathVariable Long postId ) { - postService.toggleLike(postId); - return RsData.successOf(null); // code=200, message="success" + return RsData.successOf(postService.toggleLike(postId)); // code=200, message="success" } } diff --git a/src/main/java/com/back/domain/post/post/dto/response/PostLikeResponseDto.java b/src/main/java/com/back/domain/post/post/dto/response/PostLikeResponseDto.java new file mode 100644 index 00000000..a6e52a7f --- /dev/null +++ b/src/main/java/com/back/domain/post/post/dto/response/PostLikeResponseDto.java @@ -0,0 +1,15 @@ +package com.back.domain.post.post.dto.response; + +import com.back.domain.post.post.entity.PostLike; +import com.back.domain.post.post.enums.PostLikeStatus; + +public record PostLikeResponseDto( + PostLikeStatus status +) { + + public PostLikeResponseDto(PostLike postLike) { + this( + postLike.getStatus() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/post/post/service/PostService.java b/src/main/java/com/back/domain/post/post/service/PostService.java index b5a2161f..59e8d366 100644 --- a/src/main/java/com/back/domain/post/post/service/PostService.java +++ b/src/main/java/com/back/domain/post/post/service/PostService.java @@ -7,6 +7,7 @@ import com.back.domain.post.post.dto.request.PostCreateRequestDto; import com.back.domain.post.post.dto.request.PostSortScrollRequestDto; import com.back.domain.post.post.dto.request.PostUpdateRequestDto; +import com.back.domain.post.post.dto.response.PostLikeResponseDto; import com.back.domain.post.post.dto.response.PostResponseDto; import com.back.domain.post.post.entity.Post; import com.back.domain.post.post.entity.PostImage; @@ -233,7 +234,7 @@ public void deletePost(Long postId) { // 게시글 추천(좋아요) 토글 로직 @Transactional - public void toggleLike(Long postId) { + public PostLikeResponseDto toggleLike(Long postId) { User user = rq.getActor(); // 현재 로그인한 사용자 Post post = postRepository.findById(postId) @@ -248,6 +249,8 @@ public void toggleLike(Long postId) { post.decreaseLikeCount(); // 활동 점수: 추천 취소 시 -0.1 abvScoreService.revokeForLike(user.getId()); + + return new PostLikeResponseDto(existingLike.get().getStatus()); } else { // 추천 추가 PostLike postLike = PostLike.builder() @@ -259,15 +262,17 @@ public void toggleLike(Long postId) { post.increaseLikeCount(); // 활동 점수: 추천 추가 시 +0.1 abvScoreService.awardForLike(user.getId()); - } - // 게시글 작성자에게 알림 전송 - notificationService.sendNotification( - post.getUser(), - post, - NotificationType.LIKE, - user.getNickname() + " 님이 추천을 남겼습니다." - ); + // 게시글 작성자에게 알림 전송 + notificationService.sendNotification( + post.getUser(), + post, + NotificationType.LIKE, + user.getNickname() + " 님이 추천을 남겼습니다." + ); + + return new PostLikeResponseDto(postLike.getStatus()); + } } // 태그 추가 메서드 diff --git a/src/main/java/com/back/domain/user/controller/UserAuthController.java b/src/main/java/com/back/domain/user/controller/UserAuthController.java index c0442d96..ae273914 100644 --- a/src/main/java/com/back/domain/user/controller/UserAuthController.java +++ b/src/main/java/com/back/domain/user/controller/UserAuthController.java @@ -1,6 +1,7 @@ package com.back.domain.user.controller; import com.back.domain.user.dto.RefreshTokenResDto; +import com.back.domain.user.dto.UserMeResDto; import com.back.domain.user.service.UserAuthService; import com.back.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; @@ -11,6 +12,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -50,4 +52,16 @@ public RsData logout(HttpServletRequest request, HttpServletResponse respo userAuthService.logout(request, response); return RsData.of(200, "로그아웃되었습니다."); } + + @Operation(summary = "현재 로그인한 유저 정보 조회", description = "세션 유효성 검증 및 사용자 정보 반환") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증된 유저 정보 반환 성공"), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @GetMapping("/me") + public RsData getCurrentUser() { + UserMeResDto userInfo = userAuthService.getCurrentUser(); + return RsData.of(200, "인증된 유저 정보 반환 성공", userInfo); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/dto/UserMeResDto.java b/src/main/java/com/back/domain/user/dto/UserMeResDto.java new file mode 100644 index 00000000..77efdc6a --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/UserMeResDto.java @@ -0,0 +1,28 @@ +package com.back.domain.user.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserMeResDto { + @JsonProperty("user") + private final UserInfo user; + + @Getter + @Builder + public static class UserInfo { + private final String id; + private final String email; + private final String nickname; + + @JsonProperty("is_first_login") + private final Boolean isFirstLogin; + + @JsonProperty("abv_degree") + private final Double abvDegree; + + private final String provider; + } +} diff --git a/src/main/java/com/back/domain/user/service/UserAuthService.java b/src/main/java/com/back/domain/user/service/UserAuthService.java index 6e58b8cf..ce5ef57e 100644 --- a/src/main/java/com/back/domain/user/service/UserAuthService.java +++ b/src/main/java/com/back/domain/user/service/UserAuthService.java @@ -1,6 +1,7 @@ package com.back.domain.user.service; import com.back.domain.user.dto.RefreshTokenResDto; +import com.back.domain.user.dto.UserMeResDto; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ServiceException; @@ -8,6 +9,7 @@ import com.back.global.jwt.refreshToken.entity.RefreshToken; import com.back.global.jwt.refreshToken.repository.RefreshTokenRepository; import com.back.global.jwt.refreshToken.service.RefreshTokenService; +import com.back.global.rq.Rq; import com.back.global.rsData.RsData; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -20,6 +22,8 @@ import java.util.Optional; import java.util.Set; +import static org.springframework.security.core.context.SecurityContextHolder.*; + @Slf4j @Service @RequiredArgsConstructor @@ -76,6 +80,7 @@ public class UserAuthService { private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; private final RefreshTokenRepository refreshTokenRepository; + private final Rq rq; //OAuth 관련 @@ -196,14 +201,30 @@ public RefreshTokenResDto refreshTokens(HttpServletRequest request, HttpServletR //토큰 끊기면서 OAuth 자동 로그아웃 public void logout(HttpServletRequest request, HttpServletResponse response) { + // 1. RefreshToken DB에서 삭제 String refreshToken = jwtUtil.getRefreshTokenFromCookie(request); - if (refreshToken != null) { refreshTokenService.revokeToken(refreshToken); } + // 2. JWT 쿠키 삭제 jwtUtil.removeAccessTokenCookie(response); jwtUtil.removeRefreshTokenCookie(response); + + // 3. Spring Security 세션 무효화 (Redis 포함) + try { + if (request.getSession(false) != null) { + request.getSession().invalidate(); + log.debug("세션 무효화"); + } + } catch (IllegalStateException e) { + log.debug("세션이 이미 무효화되어 있음"); + } + + // 4. SecurityContext 클리어 + clearContext(); + + log.info("로그아웃 완료 - JWT, 세션, SecurityContext 모두 정리됨"); } @Transactional @@ -211,4 +232,50 @@ public void setFirstLoginFalse(Long id) { Optional userOpt = userRepository.findById(id); userOpt.ifPresent(user -> user.setFirstLogin(false)); } + + // 현재 로그인한 사용자 정보 조회 (세션 검증용) + public UserMeResDto getCurrentUser() { + try { + User actor = rq.getActor(); + + if (actor == null) { + log.debug("인증되지 않은 사용자"); + throw new ServiceException(401, "인증되지 않은 사용자"); + } + + Optional userOpt = userRepository.findById(actor.getId()); + if (userOpt.isEmpty()) { + log.warn("사용자 ID {}를 DB에서 찾을 수 없음 (토큰은 유효하나 사용자 삭제됨)", actor.getId()); + throw new ServiceException(401, "인증되지 않은 사용자"); + } + + User user = userOpt.get(); + String provider = extractProvider(user.getOauthId()); + + return UserMeResDto.builder() + .user(UserMeResDto.UserInfo.builder() + .id(user.getId().toString()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .isFirstLogin(user.isFirstLogin()) + .abvDegree(user.getAbvDegree()) + .provider(provider) + .build()) + .build(); + + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("사용자 정보 조회 중 서버 오류 발생: {}", e.getMessage(), e); + throw new ServiceException(500, "서버 내부 오류"); + } + } + + private String extractProvider(String oauthId) { + if (oauthId == null || oauthId.isBlank()) { + return "unknown"; + } + String[] parts = oauthId.split("_", 2); + return parts.length > 0 ? parts[0] : "unknown"; + } } \ No newline at end of file diff --git a/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java b/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java index b139a66e..0a534dfe 100644 --- a/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java +++ b/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java @@ -2,10 +2,13 @@ import jakarta.persistence.*; import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Entity +@EntityListeners(AuditingEntityListener.class) @Table(name = "refresh_tokens") @Getter @Setter @@ -20,19 +23,22 @@ public class RefreshToken { @Column(nullable = false) private Long userId; - @Column(nullable = false) + @CreatedDate + @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @Column(nullable = false) private LocalDateTime expiresAt; + @Column(nullable = false) + private LocalDateTime lastUsedAt; public static RefreshToken create(String token, Long userId, long ttlSeconds) { LocalDateTime now = LocalDateTime.now(); return RefreshToken.builder() .token(token) .userId(userId) - .createdAt(now) + .lastUsedAt(now) .expiresAt(now.plusSeconds(ttlSeconds)) .build(); } @@ -40,4 +46,10 @@ public static RefreshToken create(String token, Long userId, long ttlSeconds) { public boolean isExpired() { return LocalDateTime.now().isAfter(this.expiresAt); } + + public boolean isIdleExpired(long idleTimeoutHours) { + return LocalDateTime.now().isAfter(this.lastUsedAt.plusMinutes(idleTimeoutHours)); + + } + } \ No newline at end of file diff --git a/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java b/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java index 2ae55f21..f53fbe5e 100644 --- a/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java +++ b/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java @@ -24,6 +24,9 @@ public class RefreshTokenService { @Value("${custom.refreshToken.expirationSeconds}") private long refreshTokenExpiration; + @Value("${custom.refreshToken.idleTimeoutHours}") + private long idleTimeoutHours; + // 기존 리프레시 토큰 삭제하고 생성 @Transactional public String generateRefreshToken(Long userId) { @@ -37,7 +40,8 @@ public String generateRefreshToken(Long userId) { return token; } - //검증 (만료 체크 포함) + //검증 (만료 체크 및 Idle Timeout 체크 포함) + @Transactional public boolean validateToken(String token) { Optional tokenOpt = refreshTokenRepository.findByToken(token); if (tokenOpt.isEmpty()) { @@ -45,11 +49,23 @@ public boolean validateToken(String token) { } RefreshToken refreshToken = tokenOpt.get(); + + // 1. 만료 체크 (30일) if (refreshToken.isExpired()) { revokeToken(token); // 만료된 토큰 삭제 return false; } + // 2. Idle Timeout 체크 (4시간) + if (refreshToken.isIdleExpired(idleTimeoutHours)) { + revokeToken(token); // Idle 초과 토큰 삭제 + return false; + } + + // 3. lastUsedAt 갱신 (사용 시간 업데이트) + refreshToken.setLastUsedAt(LocalDateTime.now()); + refreshTokenRepository.save(refreshToken); + return true; } diff --git a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java index ade7a695..88266bc2 100644 --- a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java +++ b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java @@ -110,32 +110,8 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt log.error("Error extracting user info from token", e); } } else { - log.warn("Access token validation failed"); - - // 토큰이 만료된 경우에도 정보 추출 시도 (선택적) - try { - Long userId = jwtUtil.getUserIdFromToken(accessToken); - String email = jwtUtil.getEmailFromToken(accessToken); - String nickname = jwtUtil.getNicknameFromToken(accessToken); - - if (userId != null && email != null && nickname != null) { - user = User.builder() - .id(userId) - .email(email) - .nickname(nickname) - .role("USER") - .build(); - - // 새 토큰 발급 (쿠키 방식을 사용하는 경우만) - if (authHeader == null) { - String newAccessToken = jwtUtil.generateAccessToken(userId, email, nickname); - rq.setCrossDomainCookie("accessToken", newAccessToken, accessTokenExpiration); - log.info("New access token issued for user: {}", userId); - } - } - } catch (Exception e) { - log.error("Failed to extract user info from expired token", e); - } + log.warn("Access token validation failed - token is expired or invalid"); + // 만료된 토큰은 인증 실패 처리 (user는 null로 유지) } // user가 null이면 인증 실패 diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index fdb4a597..4ca3cef4 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -10,6 +10,7 @@ import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -31,15 +32,18 @@ public class SecurityConfig { private final CustomOAuth2LoginSuccessHandler oauth2SuccessHandler; private final CustomOAuth2LoginFailureHandler oauth2FailureHandler; private final CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver; + private final CustomAuthenticationFilter customAuthenticationFilter; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOAuth2LoginSuccessHandler oauth2SuccessHandler, CustomOAuth2LoginFailureHandler oauth2FailureHandler, - CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver) { + CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver, + CustomAuthenticationFilter customAuthenticationFilter) { this.customOAuth2UserService = customOAuth2UserService; this.oauth2SuccessHandler = oauth2SuccessHandler; this.oauth2FailureHandler = oauth2FailureHandler; this.customOAuth2AuthorizationRequestResolver = customOAuth2AuthorizationRequestResolver; + this.customAuthenticationFilter = customAuthenticationFilter; } @Bean @@ -52,10 +56,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .maximumSessions(1) ) // OAuth 인증시 필요할때만 세션 사용 + .addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth - // 개발 편의성을 위해 모든 요청 허용 - .anyRequest().permitAll() + + .requestMatchers("/user/auth/logout").authenticated() /* .requestMatchers("/").permitAll() .requestMatchers("/h2-console/**").permitAll() @@ -63,6 +68,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/oauth2/**").permitAll() .requestMatchers("/login/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() + .requestMatchers("/user/auth/refresh").permitAll() // 권한 불필요 - 조회 API .requestMatchers(GET, "/cocktails/**").permitAll() @@ -80,6 +86,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 나머지 모든 API는 인증 필요 .anyRequest().authenticated() */ + // 개발 편의성을 위해 모든 요청 허용 + .anyRequest().permitAll() ) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b4570f2..a0594f1b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -99,9 +99,11 @@ custom: jwt: secretKey: ${JWT_SECRET_KEY} accessToken: - expirationSeconds: "#{60*15}" + expirationSeconds: "#{60}" # 15분 곱하기 refreshToken: expirationSeconds: "#{60*60*24*30}" + idleTimeoutHours: "#{1}" +# "#{60*6*4}" management: