diff --git a/build.gradle b/build.gradle index 9ae4e00..7a21ce7 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,11 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11' runtimeOnly 'com.h2database:h2' + + // JWT 의존성 추가 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } // Query DSL 설정 diff --git a/src/main/java/com/issueDive/config/SecurityConfig.java b/src/main/java/com/issueDive/config/SecurityConfig.java index c07fcce..61696a1 100644 --- a/src/main/java/com/issueDive/config/SecurityConfig.java +++ b/src/main/java/com/issueDive/config/SecurityConfig.java @@ -1,10 +1,14 @@ package com.issueDive.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +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.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -12,16 +16,25 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; 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; import java.util.Arrays; +import com.issueDive.security.CustomUserDetailsService; +import com.issueDive.security.JwtAuthenticationFilter; + @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final CustomUserDetailsService userDetailsService; // DB 기반 인증 + private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 필터 + + // 공개적으로 접근 가능한 URL 목록 private static final String[] PUBLIC_URLS = { "/swagger-ui/**", @@ -35,15 +48,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 - .csrf(AbstractHttpConfigurer::disable) + // 9월1일 변경 - 세션 관리 정책 (JWT는 stateless) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize // 모든 요청(/**)을 허용 (임시로) - .anyRequest().permitAll() // 개발 단계에서는 전체 허용 -// .requestMatchers(PUBLIC_URLS).permitAll() // 공개 URL은 모두 허용 -// .anyRequest().authenticated() // 나머지는 인증 필요 + //.anyRequest().permitAll() // 개발 단계에서는 전체 허용 + .requestMatchers(PUBLIC_URLS).permitAll() // 공개 URL은 모두 허용 + .anyRequest().authenticated() // 나머지는 인증 필요 ) .formLogin(formLogin -> formLogin.disable()) // 폼 로그인 비활성화 (필요 시) - .logout(logout -> logout.disable()); // 로그아웃 비활성화 (필요 시) + .logout(logout -> logout.disable()); + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);// 로그아웃 비활성화 (필요 시) return http.build(); } @@ -82,4 +98,9 @@ public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { return new InMemoryUserDetailsManager(user); } + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + } \ No newline at end of file diff --git a/src/main/java/com/issueDive/controller/AuthController.java b/src/main/java/com/issueDive/controller/AuthController.java index 3c785c3..f3b8712 100644 --- a/src/main/java/com/issueDive/controller/AuthController.java +++ b/src/main/java/com/issueDive/controller/AuthController.java @@ -1,24 +1,29 @@ package com.issueDive.controller; -import com.issueDive.dto.LoginRequestDTO; +import com.issueDive.dto.*; import com.issueDive.service.UserService; -import com.issueDive.dto.ApiResponse; -import com.issueDive.dto.UserRequestDTO; -import com.issueDive.dto.UserResponseDTO; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.*; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; +import com.issueDive.util.JwtUtil; @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final UserService userService; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; /** * Create User (회원가입) @@ -35,13 +40,47 @@ public ResponseEntity> signUp(@Valid @RequestBody U /** * Login * @param request 로그인 요청 DTO (email, password) - * @return 공통 응답 포맷 + 사용자 DTO (또는 인증 토큰) + * @return JWT 토큰과 사용자 정보 */ @PostMapping("/login") - public ResponseEntity> login(@Valid @RequestBody LoginRequestDTO request){ - UserResponseDTO user = userService.login(request.getUsername(), request.getPassword()); - ApiResponse response = ApiResponse.ok(user); - return new ResponseEntity<>(response, HttpStatus.OK); + public ResponseEntity> login(@Valid @RequestBody LoginRequestDTO request) { + try { + // 인증된 사용자 정보 조회 + UserResponseDTO userResponse = userService.findUserByEmail(request.getEmail()); + + // JWT AccessToken만 생성 (RefreshToken 제거) + String accessToken = jwtUtil.generateAccessToken(userResponse.getId(), userResponse.getEmail()); + + // JWT 응답 생성 (RefreshToken 제거) + JwtResponse jwtResponse = JwtResponse.of( + accessToken, + "Bearer", + 14400L, // 9월1일 변경 - 4시간 (초 단위) + userResponse + ); + return ResponseEntity.ok(ApiResponse.ok(jwtResponse)); + } catch (Exception e) { + //인증 실패시 예외 던지기 (GlobalExceptionHandler에서 처리) + throw new com.issueDive.exception.AuthenticationFailedException(); + } + } + + + /** + * 9월1일 변경 - 로그아웃 (JWT 기반에서는 클라이언트에서 토큰 삭제) + * @return 로그아웃 안내 메시지 + */ + @PostMapping("/logout") + public ResponseEntity>> logout() { + //JWT는 stateless하므로 서버에서 특별한 로그아웃 처리 불필요 + + Map responseData = Map.of( + "message", "로그아웃되었습니다. 클라이언트에서 토큰을 삭제해주세요.", + "instruction", "localStorage에서 accessToken을 제거하세요." + ); + + ApiResponse> response = ApiResponse.ok(responseData); + return ResponseEntity.ok(response); } /** diff --git a/src/main/java/com/issueDive/controller/IssueController.java b/src/main/java/com/issueDive/controller/IssueController.java index 4bd827d..b3ebd9c 100644 --- a/src/main/java/com/issueDive/controller/IssueController.java +++ b/src/main/java/com/issueDive/controller/IssueController.java @@ -64,10 +64,22 @@ public ResponseEntity> getIssue(@PathVariable Long id /** * Update * @param id 수정할 이슈 id - * @param request title, description, assignee(uid) + * @param request title, description, assignee(uid), labelIds * @return 공통 응답 포맷 + 수정된 이슈 dto */ @PutMapping("/{id}") + public ResponseEntity> patchIssue(@PathVariable Long id, + @RequestBody UpdateIssueRequest request) { + return ResponseEntity.ok(ApiResponse.ok(issueService.updateIssue(id, request))); + } + + /** + * Update (부분 수정) + * @param id 수정할 이슈 id + * @param request title, description, assigneeId, labelIds + * @return 공통 응답 포맷 + 수정된 이슈 dto + */ + @PatchMapping("/{id}") public ResponseEntity> updateIssue(@PathVariable Long id, @RequestBody UpdateIssueRequest request) { return ResponseEntity.ok(ApiResponse.ok(issueService.updateIssue(id, request))); diff --git a/src/main/java/com/issueDive/dto/JwtResponse.java b/src/main/java/com/issueDive/dto/JwtResponse.java new file mode 100644 index 0000000..dcd12dd --- /dev/null +++ b/src/main/java/com/issueDive/dto/JwtResponse.java @@ -0,0 +1,22 @@ +package com.issueDive.dto; + +import lombok.*; + +@Getter +@AllArgsConstructor +@Builder +public class JwtResponse { + private String accessToken; + private String tokenType; + private Long expiresIn; + private UserResponseDTO user; + + public static JwtResponse of (String accessToken, String tokenType, Long expiresIn, UserResponseDTO user){ + return JwtResponse.builder() + .accessToken(accessToken) + .tokenType(tokenType) + .expiresIn(expiresIn) + .user(user) + .build(); + } +} diff --git a/src/main/java/com/issueDive/dto/LoginRequestDTO.java b/src/main/java/com/issueDive/dto/LoginRequestDTO.java index 84fd31b..cb491ce 100644 --- a/src/main/java/com/issueDive/dto/LoginRequestDTO.java +++ b/src/main/java/com/issueDive/dto/LoginRequestDTO.java @@ -7,9 +7,10 @@ @Getter @Setter public class LoginRequestDTO { - @Email(message = "올바른 username이 아닙니다.") - @NotBlank(message = "username은 필수입니다.") - private String username; + + @Email(message = "올바른 email이 아닙니다.") + @NotBlank(message = "email은 필수입니다.") + private String email; @NotBlank(message = "비밀번호는 필수입니다.") private String password; diff --git a/src/main/java/com/issueDive/dto/UpdateIssueRequest.java b/src/main/java/com/issueDive/dto/UpdateIssueRequest.java index 14ae7e4..f34eee1 100644 --- a/src/main/java/com/issueDive/dto/UpdateIssueRequest.java +++ b/src/main/java/com/issueDive/dto/UpdateIssueRequest.java @@ -1,8 +1,11 @@ package com.issueDive.dto; +import java.util.List; + public record UpdateIssueRequest( String title, String description, - Long assigneeId + Long assigneeId, + List labelIds ) {} diff --git a/src/main/java/com/issueDive/exception/ErrorCode.java b/src/main/java/com/issueDive/exception/ErrorCode.java index a010f6a..87183fc 100644 --- a/src/main/java/com/issueDive/exception/ErrorCode.java +++ b/src/main/java/com/issueDive/exception/ErrorCode.java @@ -7,5 +7,6 @@ public enum ErrorCode { LabelNotFound, IssueLabelNotFound, DuplicateLabel, CommentNotFound, InvalidParentComment, - UserNotFound, DuplicateEmail, AuthenticationFailed + UserNotFound, DuplicateEmail, AuthenticationFailed, + } \ No newline at end of file diff --git a/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java b/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java index af58fec..64b9539 100644 --- a/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java @@ -1,12 +1,17 @@ package com.issueDive.exception; import com.issueDive.dto.ErrorResponse; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.MethodArgumentNotValidException; +import javax.naming.AuthenticationException; + @RestControllerAdvice public class GlobalExceptionHandler { @@ -83,4 +88,31 @@ public ResponseEntity handleIssueLabelNotFound(IssueLabelNotFound .body(ErrorResponse.of(ErrorCode.IssueLabelNotFound, e.getMessage())); } + /** + * WT 토큰 만료 예외 처리 + */ + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity handleJwtExpired(ExpiredJwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of(ErrorCode.AuthenticationFailed, "JWT 토큰이 만료되었습니다. 다시 로그인해주세요.")); + } + + /** + * JWT 토큰 형식 오류 예외 처리 + */ + @ExceptionHandler(MalformedJwtException.class) + public ResponseEntity handleJwtMalformed(MalformedJwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of(ErrorCode.AuthenticationFailed, "잘못된 형식의 JWT 토큰입니다.")); + } + + /** + * JWT 지원되지 않는 토큰 예외 처리 + */ + @ExceptionHandler(UnsupportedJwtException.class) + public ResponseEntity handleJwtUnsupported(UnsupportedJwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of(ErrorCode.AuthenticationFailed, "지원되지 않는 JWT 토큰입니다.")); + } + } diff --git a/src/main/java/com/issueDive/security/CustomUserDetailsService.java b/src/main/java/com/issueDive/security/CustomUserDetailsService.java new file mode 100644 index 0000000..e0a32af --- /dev/null +++ b/src/main/java/com/issueDive/security/CustomUserDetailsService.java @@ -0,0 +1,37 @@ +package com.issueDive.security; + +import com.issueDive.entity.User; +import com.issueDive.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CustomUserDetailsService implements UserDetailsService{ + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getEmail()) // 이메일을 username으로 사용 + .password(user.getPassword()) + .authorities(new ArrayList<>()) // 현재는 권한 없이 진행 + .build(); + } + + // 9월1일 변경 - 사용자 ID로 User 엔티티 조회 (JWT에서 사용) + public User findUserEntityByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); + } +} diff --git a/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java b/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9aad8e7 --- /dev/null +++ b/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java @@ -0,0 +1,110 @@ +package com.issueDive.security; + +import com.issueDive.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter{ + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + String jwt = getJwtFromRequest(request); + + if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + // JWT에서 이메일 추출 + String email = jwtUtil.getUserEmailFromToken(jwt); + + // 9월1일 변경 - AccessToken만 사용하므로 타입 체크 제거 + + // 토큰 유효성 검증 + if (jwtUtil.validateToken(jwt, email)) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("사용자 {} 인증 성공", email); + } else { + log.warn("유효하지 않은 JWT 토큰: {}", email); + setErrorResponse(response, "유효하지 않은 토큰입니다."); + return; + } + } + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생", e); + setErrorResponse(response, "토큰 처리 중 오류가 발생했습니다."); + return; + } + + filterChain.doFilter(request, response); + } + + /** + * 요청 헤더에서 JWT 토큰 추출 + * Authorization: Bearer + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); // "Bearer " 제거 + } + return null; + } + + /** + * 에러 응답 설정 + */ + private void setErrorResponse(HttpServletResponse response, String message) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write(String.format(""" + { + "success": false, + "error": { + "code": "Unauthorized", + "message": "%s" + }, + "timestamp": "%s" + } + """, message, java.time.LocalDateTime.now().toString())); + } + + /** + * 공개 URL에 대해서는 필터를 적용하지 않음 (9월1일 변경 - RefreshToken URL 제거) + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/auth/signup") || + path.startsWith("/auth/login") || + path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs"); + } +} diff --git a/src/main/java/com/issueDive/service/IssueService.java b/src/main/java/com/issueDive/service/IssueService.java index cb41118..abaaad6 100644 --- a/src/main/java/com/issueDive/service/IssueService.java +++ b/src/main/java/com/issueDive/service/IssueService.java @@ -9,12 +9,14 @@ import com.issueDive.exception.NotFoundException; import com.issueDive.exception.ValidationException; import com.issueDive.repository.IssueRepository; +import com.issueDive.repository.LabelRepository; import com.issueDive.repository.UserRepository; import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -26,6 +28,7 @@ public class IssueService { private final QIssue qIssue = QIssue.issue; private final IssueRepository issueRepository; private final UserRepository userRepository; // 작성자/담당자 유효성 검증용 + private final LabelRepository labelRepository; /** * Issue 생성 @@ -106,9 +109,10 @@ public IssueResponse getIssue(Long id) { /** * 수정 * @param id 수정할 이슈 id - * @param request (선택적으로) title, description, assignee(uid) + * @param request (선택적으로) title, description, assigneeId, labelIds * @return 수정한 이슈 dto */ + @Transactional public IssueResponse updateIssue(Long id, UpdateIssueRequest request) { Issue issue = issueRepository.findById(id) .orElseThrow(() -> new NotFoundException("Issue not found")); @@ -122,6 +126,17 @@ public IssueResponse updateIssue(Long id, UpdateIssueRequest request) { issue.setAssignee(assignee); } + if (request.labelIds() != null) { + // 1. 요청으로 받은 ID 목록으로 새로운 라벨 엔티티들을 조회합니다. + List