diff --git a/.gitignore b/.gitignore index c6be107..2b4b4e9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ out/ ### environment files ### .env -.env.prod \ No newline at end of file +.env.prod + +### docker test ### +docker-compose.override.yml \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/user/controller/UserController.java b/src/main/java/ceos/ipx/domain/user/controller/UserController.java new file mode 100644 index 0000000..af15a3c --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/controller/UserController.java @@ -0,0 +1,40 @@ +package ceos.ipx.domain.user.controller; + +import ceos.ipx.domain.user.dto.MyInfoResponse; +import ceos.ipx.domain.user.service.UserService; +import ceos.ipx.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "User API", description = "사용자 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + @Operation(summary = "내 정보 조회", description = "AccessToken을 기반으로 현재 로그인한 사용자의 정보를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "내 정보 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "AccessToken 없음 또는 유효하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @GetMapping("/me") + public ResponseEntity> getMyInfo( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorizationHeader + ) { + MyInfoResponse response = userService.getMyInfo(authorizationHeader); + + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/user/dto/MyInfoResponse.java b/src/main/java/ceos/ipx/domain/user/dto/MyInfoResponse.java new file mode 100644 index 0000000..6aaf2d5 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/dto/MyInfoResponse.java @@ -0,0 +1,28 @@ +package ceos.ipx.domain.user.dto; + +import ceos.ipx.domain.user.entity.User; + +public record MyInfoResponse( + Long userId, + String email, + String name, + String company, + String provider, + boolean profileCompleted +) { + public static MyInfoResponse from(User user) { + return new MyInfoResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getCompany(), + user.getProvider().name(), + isProfileCompleted(user) + ); + } + + private static boolean isProfileCompleted(User user) { + return user.getName() != null && !user.getName().isBlank() + && user.getCompany() != null && !user.getCompany().isBlank(); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/user/service/UserService.java b/src/main/java/ceos/ipx/domain/user/service/UserService.java new file mode 100644 index 0000000..0b99550 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/service/UserService.java @@ -0,0 +1,55 @@ +package ceos.ipx.domain.user.service; + +import ceos.ipx.domain.user.dto.MyInfoResponse; +import ceos.ipx.domain.user.entity.User; +import ceos.ipx.domain.user.repository.UserRepository; +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import ceos.ipx.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private static final String BEARER_PREFIX = "Bearer "; + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + public MyInfoResponse getMyInfo(String authorizationHeader) { + String accessToken = extractAccessToken(authorizationHeader); + + if (!jwtTokenProvider.validateToken(accessToken)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + return MyInfoResponse.from(user); + } + + private String extractAccessToken(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + if (!authorizationHeader.startsWith(BEARER_PREFIX)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()); + + if (accessToken.isBlank()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + return accessToken; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java b/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java index 0a8c9ab..2c2f6f9 100644 --- a/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java +++ b/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java @@ -13,11 +13,12 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; 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 ceos.ipx.global.security.jwt.JwtAuthenticationFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.util.List; @Configuration @@ -26,7 +27,7 @@ public class SecurityConfig { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; - // private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 필터 구현 후 주입 + private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 필터 구현 후 주입 @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -70,6 +71,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // ===== JWT 필터 추가 ===== // .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..2bab734 --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +package ceos.ipx.global.security.jwt; + +import ceos.ipx.domain.user.entity.User; +import ceos.ipx.domain.user.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) { + String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()); + + if (jwtTokenProvider.validateToken(accessToken)) { + Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); + + userRepository.findById(userId) + .filter(User::isActive) + .ifPresent(user -> { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + user.getId(), + null, + Collections.emptyList() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + }); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file