diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java index 38a6c74..c757d1a 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -2,7 +2,9 @@ import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.entity.TokenDto; import com.ureka.techpost.domain.auth.service.AuthService; +import com.ureka.techpost.domain.auth.utils.CookieUtil; import com.ureka.techpost.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -43,7 +45,20 @@ public ApiResponse signup( public ApiResponse reissue( @Parameter(hidden = true) HttpServletRequest request, @Parameter(hidden = true) HttpServletResponse response) { - return ApiResponse.onSuccess(authService.reissue(request, response)); + + // Request에서 토큰 추출 + String authorization = request.getHeader("Authorization"); + String accessToken = (authorization != null && authorization.startsWith("Bearer ")) ? authorization.split(" ")[1] : null; + String refreshToken = CookieUtil.getCookieValue(request, "refresh"); + + // 서비스 호출 + TokenDto tokenDto = authService.reissue(accessToken, refreshToken); + + // Response 설정 (헤더 + 쿠키) + response.setHeader("Authorization", "Bearer " + tokenDto.getAccessToken()); + response.addCookie(CookieUtil.createCookie("refresh", tokenDto.getRefreshToken(), 1209600)); + + return ApiResponse.onSuccess("재발급 성공"); } @Operation(summary = "로그인", description = "사용자 이름과 비밀번호로 로그인하여 Access Token 및 Refresh Token을 발급받습니다.") @@ -52,7 +67,13 @@ public ApiResponse login( @Parameter(description = "로그인 요청 데이터 (아이디, 비밀번호)", required = true) @Valid @RequestBody LoginDto loginDto, @Parameter(hidden = true) HttpServletResponse response) { - authService.login(loginDto, response); + // 서비스 호출 + TokenDto tokenDto = authService.login(loginDto); + + // Response 설정 + response.setHeader("Authorization", "Bearer " + tokenDto.getAccessToken()); + response.addCookie(CookieUtil.createCookie("refresh", tokenDto.getRefreshToken(), 1209600)); + return ApiResponse.onSuccess("로그인 성공"); } @@ -61,7 +82,15 @@ public ApiResponse login( public ResponseEntity logout( @Parameter(hidden = true) HttpServletRequest request, @Parameter(hidden = true) HttpServletResponse response) { - authService.logout(request, response); + // 쿠키에서 리프레시 토큰 추출 + String refreshToken = CookieUtil.getCookieValue(request, "refresh"); + + // 서비스 호출 (DB 삭제) + authService.logout(refreshToken); + + // 클라이언트 쿠키 삭제 (항상 수행) + CookieUtil.deleteCookie(response, "refresh"); + return ResponseEntity.ok("로그아웃 성공"); } } diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java b/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java index 14f1a2c..de673cd 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java @@ -68,6 +68,6 @@ public Map getAttributes() { @Override public String getName() { - return user.getProviderId(); + return user.getName(); } } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java index c81d563..0838083 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java +++ b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java @@ -20,13 +20,11 @@ @RedisHash(value = "refreshToken", timeToLive = 1209600) public class RefreshToken { - @Id - private String id; // Redis Key (일반적으로 username이나 userId 사용) + @Id + private String tokenValue; // 리프레시 토큰 값 (Key) - @Indexed - private String tokenValue; // 리프레시 토큰 값 (조회용 인덱스) - - private String username; // 사용자 식별자 + @Indexed + private String username; // 사용자 식별자 (필요 시 전체 로그아웃을 위해 인덱스 설정) public void updateToken(String tokenValue) { this.tokenValue = tokenValue; diff --git a/src/main/java/com/ureka/techpost/domain/auth/entity/TokenDto.java b/src/main/java/com/ureka/techpost/domain/auth/entity/TokenDto.java new file mode 100644 index 0000000..d7d15d3 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/entity/TokenDto.java @@ -0,0 +1,15 @@ +package com.ureka.techpost.domain.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TokenDto { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java b/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java deleted file mode 100644 index c29eb18..0000000 --- a/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ureka.techpost.domain.auth.exception; - -/** - * @file InvalidTokenException.java - @author 김동혁, 구본문 - @version 1.0 - @since 2025-12-08 - @description 이 파일은 유효하지 않은 토큰과 관련된 오류 상황에서 발생하는 사용자 정의 런타임 예외(Custom Exception) 클래스입니다. - */ -public class InvalidTokenException extends RuntimeException { - public InvalidTokenException(String message) { - super(message); - } -} diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomOAuth2FailureHandler.java similarity index 76% rename from src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java rename to src/main/java/com/ureka/techpost/domain/auth/handler/CustomOAuth2FailureHandler.java index ff43b22..d06dbcd 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomOAuth2FailureHandler.java @@ -20,7 +20,7 @@ */ @Component -public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { +public class CustomOAuth2FailureHandler implements AuthenticationFailureHandler { private final ObjectMapper objectMapper = new ObjectMapper(); @@ -31,14 +31,20 @@ public void onAuthenticationFailure( AuthenticationException exception ) throws IOException { + // 예외 메시지 추출 (OAuth2AuthenticationException 또는 기타 AuthenticationException) + String errorMessage = exception.getMessage() != null + ? exception.getMessage() + : "소셜 로그인에 실패했습니다."; + ErrorResponse errorResponse = ErrorResponse.builder() .status(HttpStatus.UNAUTHORIZED) - .code("LOGIN_FAILED") - .message("로그인에 실패했습니다.") + .code("OAUTH2_LOGIN_FAILED") + .message(errorMessage) .build(); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); } } diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java deleted file mode 100644 index cf6b890..0000000 --- a/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ureka.techpost.domain.auth.handler; - -import com.ureka.techpost.domain.auth.dto.ErrorResponseDto; -import com.ureka.techpost.domain.auth.exception.InvalidTokenException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -/** - * @file JwtGlobalExceptionHandler.java - @author 김동혁, 구본문 - @version 1.0 - @since 2025-12-08 - @description 이 파일은 애플리케이션 전역에서 발생하는 예외(토큰 오류, 로그인 실패 등)를 감지하여 표준화된 에러 응답(JSON)으로 변환해주는 글로벌 예외 처리 핸들러입니다. - */ -@RestControllerAdvice -public class JwtGlobalExceptionHandler { - - @ExceptionHandler(InvalidTokenException.class) - public ResponseEntity handleInvalidTokenException(InvalidTokenException ex) { - return ErrorResponseDto.toResponseEntity(HttpStatus.UNAUTHORIZED.value(), "토큰 오류", ex.getMessage()); - } - - @ExceptionHandler(BadCredentialsException.class) - public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { - return ErrorResponseDto.toResponseEntity(HttpStatus.UNAUTHORIZED.value(), "로그인 실패", "비밀번호가 일치하지 않습니다."); - } - - @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleRuntimeException(RuntimeException ex) { - // "이미 가입되어 있는 회원입니다." 와 같은 회원가입 시의 예외를 처리 - if ("이미 가입되어 있는 회원입니다.".equals(ex.getMessage())) { - return ErrorResponseDto.toResponseEntity(HttpStatus.CONFLICT.value(), "회원가입 오류", ex.getMessage()); - } - // 그 외 다른 런타임 예외는 일반적인 서버 오류로 처리 - return ErrorResponseDto.toResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 오류", "알 수 없는 런타임 오류가 발생했습니다."); - } -} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java index 001a71a..9cf5813 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java @@ -4,6 +4,7 @@ import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.auth.service.TokenService; +import com.ureka.techpost.domain.auth.utils.CookieUtil; import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; import jakarta.servlet.ServletException; @@ -45,7 +46,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String refresh = jwtUtil.generateRefreshToken("refresh"); tokenService.addRefreshToken(oAuth2User.getUser(), refresh); - response.addCookie(tokenService.createCookie("refresh", refresh)); + response.addCookie(CookieUtil.createCookie("refresh", refresh, 1209600)); // 액세스 토큰을 쿼리 파라미터에 담아 프론트엔드 URL로 리다이렉트 // vue.js 에서 지원하는 포트 번호로 변경해야 함 diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java index 0d8b3a7..e46ad3f 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java @@ -4,6 +4,8 @@ import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; import com.ureka.techpost.domain.auth.service.TokenService; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -31,87 +33,78 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserRepository userRepository; - private final TokenService tokenService; @Override protected boolean shouldNotFilter(HttpServletRequest request) { - String uri = request.getRequestURI(); - - // Swagger UI & 문서 경로 - if (uri.startsWith("/swagger-ui") || - uri.startsWith("/v3/api-docs") || - uri.startsWith("/swagger-resources") || - uri.startsWith("/webjars")) { - return true; - } - - // Auth 관련 public 경로 - if (uri.equals("/") || - uri.startsWith("/health") || - uri.startsWith("/actuator") || - uri.equals("/api/auth/reissue") || - uri.equals("/api/auth/signup") || - uri.equals("/api/auth/login")) { - return true; - } - - return false; + String requestURI = request.getRequestURI(); + // reissue 요청은 헤더에 access 토큰이 아닌 refresh 토큰이 필요하기 때문에, + // JwtAuthenticationFilter의 검증 로직을 건너뛰어야 함 + return requestURI.equals("/api/auth/reissue"); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - log.info("[JwtAuthFilter] doFilterInternal"); - - // 요청 헤더에서 Authorization 키의 값(토큰) 추출 + + // 토큰 추출 + String accessToken = resolveToken(request); + + // 토큰이 없으면 다음 필터로 진행 + if (accessToken == null) { + filterChain.doFilter(request, response); + return; + } + + // 토큰 유효성 검증 (실패 시 즉시 종료) + validateToken(response, accessToken); + + // 인증 처리 + authenticateUser(accessToken); + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + return authorization.split(" ")[1]; + } + return null; + } + + private void validateToken(HttpServletResponse response, String accessToken) throws IOException { + // 토큰 만료 여부 확인 + if (jwtUtil.isExpired(accessToken)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); + } + + // 토큰 카테고리 확인 + String category = jwtUtil.getCategory(accessToken); + if (!"access".equals(category)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); + } + } + + private void authenticateUser(String accessToken) { + // 토큰에서 username 추출 + String username = jwtUtil.getUsernameFromToken(accessToken); + + // DB에서 사용자 조회 + User foundUser = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다.")); + + // UserDetails 객체 생성 + CustomUserDetails customUserDetails = new CustomUserDetails(foundUser); + + // 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + // SecurityContext에 설정 + SecurityContextHolder.getContext().setAuthentication(authToken); - // 토큰이 없거나, Bearer 타입이 아니면 필터 통과 (인증 실패 처리됨) - if (authorization == null || !authorization.startsWith("Bearer ")) { - log.warn("JWT 토큰 없음"); - filterChain.doFilter(request, response); - return; - } - - // "Bearer " 접두사를 제거하고 순수 토큰 값만 추출 - String accessToken = authorization.split(" ")[1]; - - // 토큰 유효성 검증 (만료 여부, 위조 여부 등 확인) - // 유효하지 않으면 예외가 발생하여 GlobalExceptionHandler가 처리 - tokenService.validateAccessToken(accessToken); - - // 토큰에서 사용자 이름(username) 추출 - String username = jwtUtil.getUsernameFromToken(accessToken); - - // 추출한 username으로 DB에서 실제 사용자 정보 조회 - // (토큰에는 비밀번호 같은 민감한 정보가 없으므로 DB 조회가 필요할 수 있음) - User foundUser = userRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다.")); - - // 인증 객체(Authentication) 생성을 위한 임시 User 객체 생성 - // 비밀번호는 이미 토큰 검증을 통과했으므로 임의의 값으로 설정 - User user = User.builder() - .userId(foundUser.getUserId()) - .username(username) - .password("temppassword") - .name(foundUser.getName()) - .role(foundUser.getRole()) - .provider("NONE") - .providerId(null) - .build(); - - // UserDetails 객체 생성 (Spring Security가 사용하는 사용자 정보 객체) - CustomUserDetails customUserDetails = new CustomUserDetails(user); - - // 스프링 시큐리티 인증 토큰 생성 - Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); - - // 세션(Security Context)에 인증 정보 등록 - // 이 요청이 끝날 때까지만 인증된 상태로 유지됨 (Stateless) - SecurityContextHolder.getContext().setAuthentication(authToken); - - // 다음 필터로 진행 - filterChain.doFilter(request, response); } } diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java index 124ca06..38f9398 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java @@ -63,39 +63,47 @@ public String generateRefreshToken(String category) { .compact(); } - // JWT로부터 subject를 꺼내서 username 확인 + // JWT로부터 subject를 꺼내서 확인 public String getUsernameFromToken(String token) { - return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject(); + return parseClaims(token).getSubject(); } - // JWT로부터 role claim 추출 + // JWT로부터 role claim 추출 public String getRoleFromToken(String token) { - return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("role", String.class); + return parseClaims(token).get("role", String.class); } - // JWT로부터 category 추출 (access, refresh 구분) + // JWT로부터 category 추출 (access, refresh 구분) public String getCategory(String token) { - return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("category", String.class); + return parseClaims(token).get("category", String.class); } - // 토큰이 만료되었으면 true, 아니면 false + // 토큰이 만료되었으면 true, 아니면 false public Boolean isExpired(String token) { - return Jwts.parser().verifyWith(key).build().parseSignedClaims(token) - .getPayload().getExpiration().before(new Date()); + try { + parseClaims(token); + return false; + } catch (ExpiredJwtException e) { + return true; + } } // 만료된 토큰에서 username 추출 public String getUsernameFromExpirationToken(String token) { try { - return Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token) - .getPayload() - .getSubject(); + return parseClaims(token).getSubject(); } catch (ExpiredJwtException e) { // 만료된 토큰이어도 일단 내부 정보 반환(재발급 시 사용자 정보가 필요할 수 있음) return e.getClaims().getSubject(); } } + + // 토큰 파싱 공통 메서드 + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } } diff --git a/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java index a30fb0a..db1d147 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java @@ -16,12 +16,5 @@ @Repository public interface RefreshTokenRepository extends CrudRepository { - // @Indexed로 지정된 필드는 findBy 구문으로 조회 가능 - Optional findByTokenValue(String tokenValue); - - // CrudRepository는 기본적으로 Key(@Id) 기반 조회만 빠르고, Indexed 필드 조회는 보조 인덱스를 사용함. - Optional findByUsername(String username); - - void deleteByTokenValue(String tokenValue); } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java index 9d30598..0ca16c1 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -3,6 +3,7 @@ import com.ureka.techpost.domain.auth.dto.CustomUserDetails; import com.ureka.techpost.domain.auth.dto.LoginDto; import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.entity.TokenDto; import com.ureka.techpost.domain.auth.jwt.JwtUtil; import com.ureka.techpost.domain.user.entity.User; import com.ureka.techpost.domain.user.repository.UserRepository; @@ -19,6 +20,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -57,7 +59,7 @@ public void signup(SignupDto signupDto) { userRepository.save(user); } - public void login(LoginDto loginDto, HttpServletResponse response) { + public TokenDto login(LoginDto loginDto) { // 입력 데이터에서 username, password 꺼냄 String username = loginDto.getUsername(); String password = loginDto.getPassword(); @@ -76,30 +78,25 @@ public void login(LoginDto loginDto, HttpServletResponse response) { String access = jwtUtil.generateAccessToken("access", user.getUsername(), user.getUser().getName(), user.getUser().getRoleName()); String refresh = jwtUtil.generateRefreshToken("refresh"); - // 새로 발급된 리프레시 토큰을 DB에 저장 (기존 토큰이 있다면 업데이트) + // 새로 발급된 리프레시 토큰을 DB에 저장 tokenService.addRefreshToken(user.getUser(), refresh); - // 클라이언트 응답 헤더에 액세스 토큰 추가 (Bearer 타입) - response.setHeader("Authorization", "Bearer " + access); - // 클라이언트 응답 쿠키에 HttpOnly 리프레시 토큰 추가 - response.addCookie(tokenService.createCookie("refresh", refresh)); - // HTTP 응답 상태를 OK(200)로 설정 - response.setStatus(HttpStatus.OK.value()); + return TokenDto.builder() + .accessToken(access) + .refreshToken(refresh) + .build(); } // 토큰 재발급 - public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { + public TokenDto reissue(String accessToken, String refreshToken) { - String authorization = request.getHeader("Authorization"); - // Access Token 검증 - if (authorization == null || !authorization.startsWith("Bearer ")) { + // Access Token 검증 (형식 확인 등) - 이미 필터나 컨트롤러에서 Bearer 제거 후 넘어왔다고 가정 + if (accessToken == null) { throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); } - String accessToken = authorization.split(" ")[1]; - String refresh = getRefreshTokenFromCookie(request); - - tokenService.validateRefreshToken(refresh); + // Refresh 토큰 검증 + tokenService.validateRefreshToken(refreshToken); // --- 검증 통과 --- // @@ -114,52 +111,30 @@ public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse String newRefresh = jwtUtil.generateRefreshToken("refresh"); // 기존 Refresh 토큰 DB에서 삭제 후 새 Refresh 토큰 저장 - tokenService.deleteByTokenValue(refresh); + // Key가 tokenValue이므로 기존 토큰을 지우고 새 토큰을 저장해야 함 + tokenService.deleteByTokenValue(refreshToken); tokenService.addRefreshToken(foundUser, newRefresh); - // 응답 설정 - response.setHeader("Authorization", "Bearer " + newAccess); - response.addCookie(tokenService.createCookie("refresh", newRefresh)); - - return new ResponseEntity<>(HttpStatus.OK); + return TokenDto.builder() + .accessToken(newAccess) + .refreshToken(newRefresh) + .build(); } // 로그아웃 처리 - public void logout(HttpServletRequest request, HttpServletResponse response) { - String refresh = getRefreshTokenFromCookie(request); - + @Transactional + public void logout(String refreshToken) { // 토큰이 존재하면 검증 및 DB 삭제 시도 - if (refresh != null) { + if (refreshToken != null) { try { // 토큰 검증 (만료, 위조, DB 존재 여부 확인) - tokenService.validateRefreshToken(refresh); + tokenService.validateRefreshToken(refreshToken); // DB에서 Refresh 토큰 제거 - tokenService.deleteByTokenValue(refresh); + tokenService.deleteByTokenValue(refreshToken); } catch (CustomException e) { // 토큰이 유효하지 않거나(만료 등), 이미 DB에 없는 경우 - // 로그아웃 과정이므로 무시하고 쿠키 삭제로 넘어감 - } - } - - // response에서 쿠키 제거 (항상 수행하여 클라이언트 상태 정리) - Cookie cookie = new Cookie("refresh", null); - cookie.setMaxAge(0); - cookie.setPath("/"); - response.addCookie(cookie); - } - - private static String getRefreshTokenFromCookie(HttpServletRequest request) { - // Refresh 토큰 검증 - String refresh = null; - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (cookie.getName().equals("refresh")) { - refresh = cookie.getValue(); - break; - } + // 로그아웃 과정이므로 무시 } } - return refresh; } } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java index 5d67bfa..c7fdc98 100644 --- a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java +++ b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java @@ -29,35 +29,18 @@ public class TokenService { // DB에 Refresh 토큰 저장 (Redis) public void addRefreshToken(User user, String refresh) { - // Redis에 저장할 객체 생성 - // @Id 필드(id)에 user.getUsername()을 사용하여, 사용자별로 하나의 리프레시 토큰만 유지하도록 할 수 있음 - // 또는 refresh 값을 id로 사용하여 다중 로그인을 허용할 수도 있음. 여기서는 username을 키로 사용. + RefreshToken refreshToken = RefreshToken.builder() - .id(user.getUsername()) // Key: username - .username(user.getUsername()) - .tokenValue(refresh) - .build(); + .tokenValue(refresh) // Key: refresh token value + .username(user.getUsername()) + .build(); refreshTokenRepository.save(refreshToken); } - // 쿠키 생성 - public Cookie createCookie(String key, String value) { - Cookie cookie = new Cookie(key, value); - cookie.setMaxAge(1209600); // 14일 (리프레시 토큰 유효기간과 일치) - cookie.setHttpOnly(true); - return cookie; - } - - // DB에 Refresh 토큰이 존재하는지 확인 (Redis) - public Boolean existsByTokenValue(String tokenValue) { - // @Indexed 된 필드로 조회 - return refreshTokenRepository.findByTokenValue(tokenValue).isPresent(); - } - // DB에서 Refresh 토큰을 삭제 (Redis) public void deleteByTokenValue(String tokenValue) { - refreshTokenRepository.deleteByTokenValue(tokenValue); + refreshTokenRepository.deleteById(tokenValue); } // 리프레시 토큰 검증 @@ -66,39 +49,17 @@ public void validateRefreshToken(String token) { throw new CustomException(ErrorCode.REFRESH_TOKEN_MISSING); } - try { - jwtUtil.isExpired(token); - } catch (ExpiredJwtException e) { - throw new CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED); - } + if (jwtUtil.isExpired(token)) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED); + } String category = jwtUtil.getCategory(token); if (!category.equals("refresh")) { throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); } - if (!existsByTokenValue(token)) { + if (!refreshTokenRepository.existsById(token)) { throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } } - - // 액세스 토큰 검증 - public void validateAccessToken(String token) { - if (token == null) { - throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); - } - - try { - jwtUtil.isExpired(token); - } catch (ExpiredJwtException e) { - throw new CustomException(ErrorCode.ACCESS_TOKEN_EXPIRED); - } - - String category = jwtUtil.getCategory(token); - if (!category.equals("access")) { - throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); - } - - } - } \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/utils/CookieUtil.java b/src/main/java/com/ureka/techpost/domain/auth/utils/CookieUtil.java new file mode 100644 index 0000000..0b5fbf7 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/utils/CookieUtil.java @@ -0,0 +1,38 @@ +package com.ureka.techpost.domain.auth.utils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class CookieUtil { + + public static Cookie createCookie(String key, String value, int maxAge) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(maxAge); + cookie.setHttpOnly(true); + cookie.setPath("/"); + // HTTPS 환경이라면 secure 설정도 고려 + // cookie.setSecure(true); + + return cookie; + } + + public static String getCookieValue(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return cookie.getValue(); + } + } + } + return null; + } + + public static void deleteCookie(HttpServletResponse response, String name) { + Cookie cookie = new Cookie(name, null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java index 3de401e..9ad6c00 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java @@ -23,10 +23,13 @@ public class ChatRoomRes { private String roomName; - public static ChatRoomRes from(ChatRoom chatRoom) { + private Long participantCount; + + public static ChatRoomRes from(ChatRoom chatRoom, long participantCount) { return ChatRoomRes.builder() .roomId(chatRoom.getId()) .roomName(chatRoom.getRoomName()) + .participantCount(participantCount) .build(); } } diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java index 0d888cd..7b50031 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java @@ -23,4 +23,6 @@ public interface ChatParticipantRepository extends JpaRepository findByUserAndChatRoom(User user, ChatRoom chatRoom); boolean existsByUserAndChatRoom(User user, ChatRoom chatRoom); + + long countByChatRoomId(Long roomId); } diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java index 68e1472..b06aab0 100644 --- a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -41,7 +41,7 @@ public List getChatRoomList() { List chatRoomResList = new ArrayList<>(); for (ChatRoom chatRoom : chatRoomList) - chatRoomResList.add(ChatRoomRes.from(chatRoom)); + chatRoomResList.add(ChatRoomRes.from(chatRoom, chatParticipantRepository.countByChatRoomId(chatRoom.getId()))); return chatRoomResList; } @@ -107,7 +107,7 @@ public List getChatHistory(Long roomId, CustomUserDetails userDe public List getMyChatRoomList(CustomUserDetails userDetails) { return chatParticipantRepository.findAllWithChatRoomByUserId(userDetails.getUser().getUserId()) .stream() - .map(chatParticipant -> ChatRoomRes.from(chatParticipant.getChatRoom())) + .map(chatParticipant -> ChatRoomRes.from(chatParticipant.getChatRoom(), chatParticipantRepository.countByChatRoomId(chatParticipant.getChatRoom().getId()))) .toList(); } diff --git a/src/main/java/com/ureka/techpost/domain/likes/repository/LikesRepository.java b/src/main/java/com/ureka/techpost/domain/likes/repository/LikesRepository.java index 48f76d4..b0abf96 100644 --- a/src/main/java/com/ureka/techpost/domain/likes/repository/LikesRepository.java +++ b/src/main/java/com/ureka/techpost/domain/likes/repository/LikesRepository.java @@ -10,5 +10,6 @@ public interface LikesRepository extends JpaRepository { boolean existsByUserAndPost(User user, Post post); + boolean existsByUserUserIdAndPostId(Long userId, Long postId); Optional findByUserAndPost(User user, Post post); } diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java index 5320e03..c47acab 100644 --- a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -56,9 +56,9 @@ public ApiResponse> searchPosts( @Operation(summary = "게시글 상세 조회", description = "게시글 ID(PK)를 이용하여 특정 게시글의 상세 정보를 조회합니다.") @GetMapping("/{postId}") - public ApiResponse getPost(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ + public ApiResponse getPost(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails){ - return ApiResponse.onSuccess(postService.findById(postId)); + return ApiResponse.onSuccess(postService.findById(postId, userDetails)); } @Operation(summary = "게시글 삭제", description = "게시글 ID와 로그인한 유저 정보를 비교하여 본인의 게시글을 삭제합니다.") diff --git a/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java b/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java index 8e0d53e..3010c08 100644 --- a/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java +++ b/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java @@ -1,5 +1,6 @@ package com.ureka.techpost.domain.post.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,6 +20,7 @@ @Setter @NoArgsConstructor @AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class PostResponseDTO { private Long id; private String title; @@ -32,4 +34,32 @@ public class PostResponseDTO { private Long likeCount; private Long commentCount; + private Boolean isLiked; + + public PostResponseDTO( + Long id, + String title, + String summary, + String originalUrl, + String thumbnailUrl, + String publisher, + LocalDateTime publishedAt, + String sourceName, + LocalDateTime createdAt, + Long likeCount, + Long commentCount + ) { + this.id = id; + this.title = title; + this.summary = summary; + this.originalUrl = originalUrl; + this.thumbnailUrl = thumbnailUrl; + this.publisher = publisher; + this.publishedAt = publishedAt; + this.sourceName = sourceName; + this.createdAt = createdAt; + this.likeCount = likeCount; + this.commentCount = commentCount; + this.isLiked = false; + } } diff --git a/src/main/java/com/ureka/techpost/domain/post/service/PostService.java b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java index 0477d5b..5e4d0b7 100644 --- a/src/main/java/com/ureka/techpost/domain/post/service/PostService.java +++ b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java @@ -1,6 +1,8 @@ package com.ureka.techpost.domain.post.service; import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.likes.entity.Likes; +import com.ureka.techpost.domain.likes.repository.LikesRepository; import com.ureka.techpost.domain.post.dto.PostResponseDTO; import com.ureka.techpost.domain.post.dto.PostRequestDTO; import com.ureka.techpost.domain.post.entity.Post; @@ -23,11 +25,21 @@ public class PostService { private final PostRepository postRepository; + private final LikesRepository likesRepository; - public PostResponseDTO findById(Long id) { - - return postRepository.findPostById(id) + public PostResponseDTO findById(Long id, CustomUserDetails userDetails) { + PostResponseDTO dto = postRepository.findPostById(id) .orElseThrow(() -> new IllegalArgumentException("해당 게시글 없음")); + + if (userDetails != null) { + Long userId = userDetails.getUser().getUserId(); + boolean liked = likesRepository.existsByUserUserIdAndPostId(userId, id); + dto.setIsLiked(liked); + } else { + dto.setIsLiked(false); + } + + return dto; } public void save(PostRequestDTO postRequestDTO, CustomUserDetails userDetails) { diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 0ade9c5..8068597 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -2,7 +2,7 @@ import com.ureka.techpost.domain.auth.handler.CustomAccessDeniedHandler; import com.ureka.techpost.domain.auth.handler.CustomAuthenticationEntryPoint; -import com.ureka.techpost.domain.auth.handler.CustomAuthenticationFailureHandler; +import com.ureka.techpost.domain.auth.handler.CustomOAuth2FailureHandler; import com.ureka.techpost.domain.auth.handler.OAuth2LoginSuccessHandler; import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; import com.ureka.techpost.domain.auth.jwt.JwtUtil; @@ -34,7 +34,6 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final UserRepository userRepository; - private final TokenService tokenService; private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @@ -71,7 +70,7 @@ public CorsConfigurationSource corsConfigurationSource() { @Bean public SecurityFilterChain filterChain(HttpSecurity http, CustomAuthenticationEntryPoint authenticationEntryPoint, - CustomAuthenticationFailureHandler authenticationFailureHandler, + CustomOAuth2FailureHandler customOAuth2FailureHandler, CustomAccessDeniedHandler AccessDeniedHandler) throws Exception { http @@ -96,10 +95,10 @@ public SecurityFilterChain filterChain(HttpSecurity http, .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig .userService(customOAuth2UserService)) .successHandler(oAuth2LoginSuccessHandler) - .failureHandler(authenticationFailureHandler) + .failureHandler(customOAuth2FailureHandler) ) - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java index da4828e..473f7f0 100644 --- a/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java @@ -14,6 +14,7 @@ import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; // connection 객체 관리 // 실시간 서버에서 문제 => 연결 객체 많아져서 서버 과부화되는 것 => 적절한 제거 필요함 @@ -35,7 +36,7 @@ public void connectHandle(SessionConnectEvent event) { } @EventListener - public void disconnectHandle(SessionConnectEvent event) { + public void disconnectHandle(SessionDisconnectEvent event) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); sessions.remove(accessor.getSessionId()); // 세션 삭제 diff --git a/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java b/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java index 6a135da..daa4a07 100644 --- a/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java +++ b/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java @@ -58,7 +58,7 @@ void entryPoint_returnsUnauthorizedJson() throws Exception { @Test // AuthenticationFailureHandler: 로그인 실패 시 401 코드와 JSON 응답을 반환하는지 검증 void failureHandler_returnsUnauthorizedJson() throws Exception { - var failureHandler = new CustomAuthenticationFailureHandler(); + var failureHandler = new CustomOAuth2FailureHandler(); var request = new MockHttpServletRequest(); var response = new MockHttpServletResponse(); @@ -71,7 +71,7 @@ void failureHandler_returnsUnauthorizedJson() throws Exception { // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); assertEquals("UNAUTHORIZED", body.get("status").asText()); - assertEquals("LOGIN_FAILED", body.get("code").asText()); + assertEquals("OAUTH2_LOGIN_FAILED", body.get("code").asText()); assertFalse(body.get("message").asText().isBlank()); } }