diff --git a/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java b/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java index d441459a..305eb66e 100644 --- a/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java +++ b/src/main/java/com/example/Veco/domain/issue/service/IssueQueryService.java @@ -250,17 +250,18 @@ public Pageable> getIssuesByTeamId( // 담당자가 없는 이슈의 개수 조회 Long unassignedCount = issueRepository.findUnassignedIssuesCountByTeamId(teamId); + List sortedManagersList = map.keySet().stream().sorted().collect(Collectors.toList()); map.put("담당자 없음", Math.toIntExact(unassignedCount)); - managers.add("담당자 없음"); + sortedManagersList.addFirst("담당자 없음"); // firstCursor가 담당자 리스트에 포함되어있는지 확인 - if (!managers.contains(firstCursor)) { + if (!sortedManagersList.contains(firstCursor)) { // 없으면 담당자 없음부터 firstCursor = "담당자 없음"; } // 담당자 별 데이터 분류 - for (String filter : map.keySet().stream().sorted().toList()) { + for (String filter : sortedManagersList) { List result = new ArrayList<>(); // firstCursor가 일치할 때, 조회 시작 @@ -320,16 +321,18 @@ public Pageable> getIssuesByTeamId( // 목표가 없는 이슈의 개수 조회 Long noGoalCount = issueRepository.findNoGoalIssuesCountByTeamId(teamId); + List sortedGoalsList = map.keySet().stream().sorted().collect(Collectors.toList()); map.put("목표 없음", Math.toIntExact(noGoalCount)); + sortedGoalsList.addFirst("목표 없음"); // firstCursor가 목표 리스트에 포함되어있는지 확인 - if (!goals.contains(firstCursor)) { + if (!sortedGoalsList.contains(firstCursor)) { // 없으면 목표 없음부터 firstCursor = "목표 없음"; } // 목표 별 데이터 분류 - for (String filter : map.keySet().stream().sorted().toList()) { + for (String filter : sortedGoalsList) { List result = new ArrayList<>(); // firstCursor가 일치할 때, 조회 시작 diff --git a/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java b/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java index fa670513..860d052e 100644 --- a/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java +++ b/src/main/java/com/example/Veco/domain/workspace/controller/SettingRestController.java @@ -158,7 +158,15 @@ public ApiResponse> getWo List result = workspaceQueryService.getWorkspaceMembers(member); - return ApiResponse.onSuccess(result); + List sorted = result.stream() + .sorted((m1, m2) -> { + if (m1.getMemberId().equals(member.getId())) return -1; //나 자신은 항상 상단 + if (m2.getMemberId().equals(member.getId())) return 1; + return m1.getJoinedAt().compareTo(m2.getJoinedAt()); //참여일 순 + }) + .toList(); + + return ApiResponse.onSuccess(sorted); } /** diff --git a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java index 64a6370a..82e814ff 100644 --- a/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java +++ b/src/main/java/com/example/Veco/domain/workspace/service/WorkspaceCommandServiceImpl.java @@ -135,10 +135,6 @@ public void updateTeamOrder(WorkSpace workspace, List teamIdList) { @Override public WorkspaceResponseDTO.CreateWorkspaceResponseDto createWorkspace(Member member, WorkspaceRequestDTO.WorkspaceRequestDto request) { - // 1. 워크스페이스 이름 중복 검사 - if (workspaceRepository.existsByName(request.getWorkspaceName())) { - throw new WorkspaceHandler(WorkspaceErrorStatus._DUPLICATE_WORKSPACE_NAME); - } // 2. 이미 워크스페이스가 있으면 예외 if (member.getWorkSpace() != null) { throw new WorkspaceHandler(WorkspaceErrorStatus._WORKSPACE_DUPLICATED); diff --git a/src/main/java/com/example/Veco/global/auth/jwt/controller/JwtController.java b/src/main/java/com/example/Veco/global/auth/jwt/controller/JwtController.java index b404ec74..7b11d26d 100644 --- a/src/main/java/com/example/Veco/global/auth/jwt/controller/JwtController.java +++ b/src/main/java/com/example/Veco/global/auth/jwt/controller/JwtController.java @@ -11,7 +11,6 @@ import com.example.Veco.global.auth.jwt.util.JwtUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -53,6 +52,7 @@ public ApiResponse reissue(HttpServletRequest request, HttpServletResp ResponseCookie refreshTokenCookie = jwtUtil.createRefreshTokenCookie(newRefreshToken); response.addHeader("Set-Cookie", refreshTokenCookie.toString()); } + log.info("토큰 재발급 성공: {}", newAccessToken); return ApiResponse.onSuccess(TokenConverter.toTokenDTO(newAccessToken)); } catch (CustomJwtException e) { log.error("토큰 재발급 실패: {}", e.getMessage()); diff --git a/src/main/java/com/example/Veco/global/auth/jwt/filter/JwtAuthFilter.java b/src/main/java/com/example/Veco/global/auth/jwt/filter/JwtAuthFilter.java index a4b73d2f..dcc75183 100644 --- a/src/main/java/com/example/Veco/global/auth/jwt/filter/JwtAuthFilter.java +++ b/src/main/java/com/example/Veco/global/auth/jwt/filter/JwtAuthFilter.java @@ -1,11 +1,13 @@ package com.example.Veco.global.auth.jwt.filter; +import com.example.Veco.global.apiPayload.ApiResponse; import com.example.Veco.global.auth.jwt.exception.CustomJwtException; import com.example.Veco.global.auth.jwt.exception.code.JwtErrorCode; import com.example.Veco.global.auth.jwt.util.JwtUtil; import com.example.Veco.global.auth.user.exception.UserException; import com.example.Veco.global.auth.user.exception.code.UserErrorCode; import com.example.Veco.global.auth.user.service.CustomUserDetailsService; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import jakarta.servlet.FilterChain; @@ -15,6 +17,7 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,7 +26,9 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.LocalDateTime; import java.util.Arrays; +import java.util.Map; @Slf4j @RequiredArgsConstructor @@ -31,6 +36,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final CustomUserDetailsService customUserDetailsService; + private final ObjectMapper objectMapper = new ObjectMapper(); private static final AntPathMatcher pathMatcher = new AntPathMatcher(); private static final String[] excludePaths = {"/healthcheck", "/api/test/login/", "/api/token/reissue", "/login-test.html", @@ -62,7 +68,15 @@ protected void doFilterInternal( // Bearer이면 추출 token = token.replace("Bearer ", ""); // AccessToken 검증하기: 올바른 토큰이면 - if (jwtUtil.isAccessTokenValid(token)) { + if (!jwtUtil.isAccessTokenValid(token)) { + log.error("JWT 토큰이 유효하지 않습니다: {}", token); + sendErrorResponse(response, JwtErrorCode.JWT_INVALID_TOKEN); + return; + } else if (jwtUtil.isTokenExpired(token)) { + log.error("JWT 토큰이 만료되었습니다: {}", token); + sendErrorResponse(response, JwtErrorCode.JWT_EXPIRED_TOKEN); + return; + } else { // 토큰에서 이메일 추출 String uid = jwtUtil.getUsername(token); UserDetails user = customUserDetailsService.loadUserByUsername(uid); @@ -76,11 +90,27 @@ protected void doFilterInternal( } filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { - throw new CustomJwtException(JwtErrorCode.JWT_EXPIRED_TOKEN); + log.error("JWT 토큰 만료: {}", e.getMessage()); + sendErrorResponse(response, JwtErrorCode.JWT_EXPIRED_TOKEN); } catch (MalformedJwtException e) { - throw new CustomJwtException(JwtErrorCode.JWT_MALFORMED_TOKEN); + log.error("JWT 형식 오류: {}", e.getMessage()); + sendErrorResponse(response, JwtErrorCode.JWT_MALFORMED_TOKEN); } catch (UserException e) { throw new UserException(UserErrorCode.USER_NOT_FOUND); } } + + private void sendErrorResponse(HttpServletResponse response, JwtErrorCode errorCode) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + + objectMapper.writeValue( + response.getOutputStream(), + ApiResponse.onFailure( + errorCode.getCode(), + errorCode.getMessage(), + null + ) + ); + } } diff --git a/src/main/java/com/example/Veco/global/auth/jwt/util/JwtUtil.java b/src/main/java/com/example/Veco/global/auth/jwt/util/JwtUtil.java index fd65c2de..3dd3ba0d 100644 --- a/src/main/java/com/example/Veco/global/auth/jwt/util/JwtUtil.java +++ b/src/main/java/com/example/Veco/global/auth/jwt/util/JwtUtil.java @@ -103,11 +103,13 @@ public boolean isAccessTokenValid(String token) { try { // 토큰이 블랙리스트에 존재하는지 확인 if (isBlackList(token)){ + log.info("토큰이 블랙리스트에 존재합니다: {}", token); return false; } getClaims(token); return true; } catch (CustomJwtException e) { + log.error("토큰 유효성 검사 실패: {}", e.getMessage()); return false; } } diff --git a/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java index ac0d9f6a..0d645649 100644 --- a/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/Veco/global/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -72,7 +72,7 @@ public void onAuthenticationSuccess( memberCommandService.saveMember(member); // 로딩 화면으로 리다이렉트 - redirectURL = UriComponentsBuilder.fromUriString("https://web.vecoservice.shop/onboarding/loading") + redirectURL = UriComponentsBuilder.fromUriString("http://localhost:5173/onboarding/loading") .build() .encode(StandardCharsets.UTF_8) .toUriString(); diff --git a/src/main/java/com/example/Veco/global/config/SecurityConfig.java b/src/main/java/com/example/Veco/global/config/SecurityConfig.java index ab5eacce..2a5516f7 100644 --- a/src/main/java/com/example/Veco/global/config/SecurityConfig.java +++ b/src/main/java/com/example/Veco/global/config/SecurityConfig.java @@ -3,21 +3,19 @@ import com.example.Veco.global.auth.jwt.filter.JwtAuthFilter; import com.example.Veco.global.auth.jwt.util.JwtUtil; import com.example.Veco.global.auth.oauth2.handler.OAuth2SuccessHandler; -import com.example.Veco.global.auth.oauth2.resolver.CustomAuthorizationRequestResolver; import com.example.Veco.global.auth.oauth2.service.OAuth2UserService; import com.example.Veco.global.auth.user.service.CustomUserDetailsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfigurationSource; @@ -31,42 +29,46 @@ public class SecurityConfig { private final CustomUserDetailsService customUserDetailsService; private final OAuth2UserService customOAuth2UserService; private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final CustomAuthorizationRequestResolver customAuthorizationRequestResolver; private final JwtUtil jwtUtil; private final CorsConfigurationSource corsConfigurationSource; @Bean - protected SecurityFilterChain configure(HttpSecurity http) throws Exception { + @Order(1) + public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { http + .securityMatcher("/api/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**") .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) - .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/test/**", "/api/teams/*/externals/**", "/api/teams/*/externals", "/api/test/login", "/api/token/reissue", "/login-test.html", "/v3/api-docs/**", "/swagger-ui/**", - "/swagger-resources/**", "/css/**", "/images/**", "/healthcheck", - "/js/**", "/h2-console/**", "/profile","/workspace/create-url","/slack/callback", "/github/**","/api/github/**").permitAll() + .requestMatchers("/api/test/**", "/api/token/reissue", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll() .anyRequest().authenticated() ) - .httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) - .logout(logout -> logout.logoutSuccessUrl("https://web.vecoservice.shop/onboarding")) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } + + // 웹용 SecurityFilterChain (OAuth2만) + @Bean + @Order(2) + public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/healthcheck", "/css/**", "/js/**", "/images/**").permitAll() + .anyRequest().authenticated() + ) .oauth2Login(oauth2 -> oauth2 - .authorizationEndpoint(config -> config - .authorizationRequestRepository(new HttpSessionOAuth2AuthorizationRequestRepository()) - .authorizationRequestResolver(customAuthorizationRequestResolver) - ) .loginPage("https://web.vecoservice.shop/onboarding") .successHandler(oAuth2SuccessHandler) .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .failureHandler((request, response, exception) -> { - // 로그인 실패 시 처리 로직 - log.error("OAuth2 로그인 실패: {}", exception.getMessage()); response.sendRedirect("https://web.vecoservice.shop/onboarding"); }) ); + return http.build(); }