Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,13 @@
/** 경매장 거래 내역 POJO Repository - Mock 또는 Stub 으로 대체해 단위 테스트 용이성 확보 */
public interface AuctionHistoryRepositoryPort {

List<AuctionHistory> findAllByAuctionBuyIds(List<String> auctionBuyIds);

Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable);

Optional<AuctionHistory> findByIdWithOptions(String id);

boolean existsByAuctionBuyIds(List<String> ids);

List<String> findExistingIds(List<String> ids);

boolean existsByAuctionBuyIdIn(List<String> ids);

Optional<AuctionHistory> findById(String id);

void saveAll(List<AuctionHistory> newEntities);

Optional<Instant> findLatestDateAuctionBuyBySubCategory(ItemCategory itemCategory);

List<Object[]> findDistinctItemInfo();
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,11 @@ select MAX(a.dateAuctionBuy)

@EntityGraph(attributePaths = "itemOptions")
Optional<AuctionHistory> findWithItemOptionsByAuctionBuyId(String id);

@Query(
"""
select distinct a.itemName, a.itemTopCategory, a.itemSubCategory
from AuctionHistory a
""")
List<Object[]> findDistinctItemInfo();
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,36 +27,11 @@ public class AuctionHistoryRepositoryPortImpl implements AuctionHistoryRepositor
@Value("${spring.jpa.properties.hibernate.jdbc.batch_size:500}")
private int batchSize;

@Override
public List<AuctionHistory> findAllByAuctionBuyIds(List<String> auctionBuyIds) {
return jpaRepository.findAllByAuctionBuyIdIn(auctionBuyIds);
}

@Override
public Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable) {
return queryDslRepository.search(condition, pageable);
}

@Override
public Optional<AuctionHistory> findByIdWithOptions(String id) {
return jpaRepository.findWithItemOptionsByAuctionBuyId(id);
}

@Override
public boolean existsByAuctionBuyIds(List<String> ids) {
return jpaRepository.existsByAuctionBuyIdIn(ids);
}

@Override
public List<String> findExistingIds(List<String> ids) {
return jpaRepository.findExistingIds(ids);
}

@Override
public boolean existsByAuctionBuyIdIn(List<String> ids) {
return jpaRepository.existsByAuctionBuyIdIn(ids);
}

@Override
public Optional<AuctionHistory> findById(String id) {
return jpaRepository.findById(id);
Expand All @@ -83,4 +58,9 @@ public Optional<Instant> findLatestDateAuctionBuyBySubCategory(ItemCategory item
return jpaRepository.findLatestDateAuctionBuyBySubCategory(
itemCategory.getTopCategory(), itemCategory.getSubCategory());
}

@Override
public List<Object[]> findDistinctItemInfo() {
return jpaRepository.findDistinctItemInfo();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package until.the.eternity.common.entity;

import jakarta.servlet.http.HttpServletRequest;
import lombok.Getter;
import org.springframework.security.web.authentication.WebAuthenticationDetails;

@Getter
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {

private final String realRemoteAddress;

public CustomWebAuthenticationDetails(HttpServletRequest request, String realIp) {
super(request);
this.realRemoteAddress = realIp;
}

@Override
public String toString() {
return "CustomWebAuthenticationDetails [RemoteIpAddress(Custom)="
+ realRemoteAddress
+ ", SessionId="
+ getSessionId()
+ ", OriginalRemoteAddress(WebDetails)="
+ super.getRemoteAddress()
+ "]";
}
}
110 changes: 110 additions & 0 deletions src/main/java/until/the/eternity/common/filter/GatewayAuthFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package until.the.eternity.common.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import until.the.eternity.common.entity.CustomWebAuthenticationDetails;
import until.the.eternity.common.util.IpAddressUtil;

/**
* Gateway에서 전달한 인증 헤더(X-Auth-*)를 기반으로 Spring Security의 Authentication을 생성하는 필터
*
* <p>Gateway에서 전달하는 헤더: - X-Auth-User-Id: 사용자 ID (Long) - X-Auth-Username: 사용자 이메일/username
* (String) - X-Auth-Roles: 사용자 역할 (예: ROLE_USER, ROLE_ADMIN)
Comment on lines +25 to +26
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The Javadoc comment states "Gateway에서 전달한 인증 헤더(X-Auth-*)를 기반으로..." but the actual description format has a line break issue. The description text appears on line 25 without proper spacing from the <p> tag on line 25.

Consider reformatting for better readability:

/**
 * Gateway에서 전달한 인증 헤더(X-Auth-*)를 기반으로 Spring Security의 Authentication을 생성하는 필터
 *
 * <p>Gateway에서 전달하는 헤더:
 * <ul>
 * <li>X-Auth-User-Id: 사용자 ID (Long)</li>
 * <li>X-Auth-Username: 사용자 이메일/username (String)</li>
 * <li>X-Auth-Roles: 사용자 역할 (예: ROLE_USER, ROLE_ADMIN)</li>
 * </ul>
 */
Suggested change
* <p>Gateway에서 전달하는 헤더: - X-Auth-User-Id: 사용자 ID (Long) - X-Auth-Username: 사용자 이메일/username
* (String) - X-Auth-Roles: 사용자 역할 (: ROLE_USER, ROLE_ADMIN)
* <p>Gateway에서 전달하는 헤더:
* <ul>
* <li>X-Auth-User-Id: 사용자 ID (Long)</li>
* <li>X-Auth-Username: 사용자 이메일/username (String)</li>
* <li>X-Auth-Roles: 사용자 역할 (: ROLE_USER, ROLE_ADMIN)</li>
* </ul>

Copilot uses AI. Check for mistakes.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GatewayAuthFilter extends OncePerRequestFilter {

private final IpAddressUtil ipAddressUtil;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {

// Gateway에서 전달한 인증 헤더 읽기
String userIdHeader = request.getHeader("X-Auth-User-Id");
String usernameHeader = request.getHeader("X-Auth-Username");
String rolesHeader = request.getHeader("X-Auth-Roles");

UsernamePasswordAuthenticationToken authentication =
getAuthentication(userIdHeader, usernameHeader, rolesHeader);

String clientIp = ipAddressUtil.getClientIp(request);

CustomWebAuthenticationDetails webAuthenticationDetails =
new CustomWebAuthenticationDetails(request, clientIp);

authentication.setDetails(webAuthenticationDetails);

SecurityContextHolder.getContext().setAuthentication(authentication);

log.debug("Authentication set for user: {} with roles: {}", usernameHeader, rolesHeader);

filterChain.doFilter(request, response);
}

/**
* Gateway에서 전달한 헤더 정보를 기반으로 Authentication 생성
*
* @param userIdHeader 사용자 ID (Gateway에서 검증됨)
* @param usernameHeader 사용자 이메일/username (Gateway에서 검증됨)
* @param rolesHeader 사용자 역할 (Gateway에서 검증됨)
* @return UsernamePasswordAuthenticationToken
*/
private UsernamePasswordAuthenticationToken getAuthentication(
String userIdHeader, String usernameHeader, String rolesHeader) {

// User ID 파싱
Long userId = null;
try {
if (userIdHeader != null) {
userId = Long.parseLong(userIdHeader);
}
} catch (NumberFormatException e) {
log.warn("Invalid user ID header: {}", userIdHeader);
}

// Roles가 없으면 익명 사용자로 처리
if (rolesHeader == null || rolesHeader.isEmpty()) {
log.debug("No roles found, creating anonymous authentication");
return new UsernamePasswordAuthenticationToken(userId, null);
}
Comment on lines +85 to +89
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When rolesHeader is null or empty, the method creates an authentication with no authorities (line 88). This creates an authenticated user without any roles, which might not be the intended behavior.

Consider whether this should actually be treated as an anonymous/unauthenticated request instead, or if you want to require roles for all authenticated requests. If the current behavior is intentional, add a comment explaining that authenticated users without roles are valid in this system.

Copilot uses AI. Check for mistakes.

// Roles 파싱 (ROLE_USER, ROLE_ADMIN 등)
List<GrantedAuthority> authorities = new ArrayList<>();

// Gateway에서 넘어온 role이 이미 "ROLE_" prefix를 가지고 있으므로 그대로 사용
// 단, "ROLE_" prefix가 없으면 추가
String role = rolesHeader.trim();
if (!role.startsWith("ROLE_")) {
role = "ROLE_" + role;
}
authorities.add(new SimpleGrantedAuthority(role));

log.debug(
"Created authentication for userId: {}, username: {}, role: {}",
userId,
usernameHeader,
role);
Comment on lines +96 to +106
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The role parsing logic only handles a single role (line 96), but the header name X-Auth-Roles (plural) and the variable name rolesHeader suggest it should support multiple roles. If multiple roles are passed (e.g., "ROLE_USER,ROLE_ADMIN"), only one SimpleGrantedAuthority will be created for the entire string "ROLE_USER,ROLE_ADMIN".

If multiple roles are supported, the logic should split the header by a delimiter (e.g., comma) and create a SimpleGrantedAuthority for each role:

String[] roles = rolesHeader.split(",");
for (String role : roles) {
    role = role.trim();
    if (!role.startsWith("ROLE_")) {
        role = "ROLE_" + role;
    }
    authorities.add(new SimpleGrantedAuthority(role));
}

If only single role is supported, consider renaming the header to X-Auth-Role (singular) for clarity.

Suggested change
String role = rolesHeader.trim();
if (!role.startsWith("ROLE_")) {
role = "ROLE_" + role;
}
authorities.add(new SimpleGrantedAuthority(role));
log.debug(
"Created authentication for userId: {}, username: {}, role: {}",
userId,
usernameHeader,
role);
String[] roles = rolesHeader.split(",");
List<String> parsedRoles = new ArrayList<>();
for (String r : roles) {
String role = r.trim();
if (!role.startsWith("ROLE_")) {
role = "ROLE_" + role;
}
authorities.add(new SimpleGrantedAuthority(role));
parsedRoles.add(role);
}
log.debug(
"Created authentication for userId: {}, username: {}, roles: {}",
userId,
usernameHeader,
parsedRoles);

Copilot uses AI. Check for mistakes.

return new UsernamePasswordAuthenticationToken(userId, null, authorities);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public record PageRequestDto(
example = "dateAuctionBuy")
SortField sortBy,
@Schema(description = "정렬 방향 (ASC, DESC)", example = "DESC") SortDirection direction) {

private static final int DEFAULT_PAGE = 1;
private static final int DEFAULT_SIZE = 20;
private static final SortField DEFAULT_SORT_BY = SortField.DATE_AUCTION_BUY;
Expand Down
41 changes: 41 additions & 0 deletions src/main/java/until/the/eternity/common/util/IpAddressUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package until.the.eternity.common.util;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@Component
public class IpAddressUtil {

public String getClientIp(HttpServletRequest request) {

String ip = request.getHeader("X-Forwarded-For");

if (!isIpFound(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (!isIpFound(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (!isIpFound(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (!isIpFound(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}

if (!isIpFound(ip)) {
ip = request.getRemoteAddr();
}

if (StringUtils.hasText(ip) && ip.contains(",")) {
return ip.split(",")[0].trim();
}

return ip;
}

private boolean isIpFound(String ip) {
return StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip);
}
}
43 changes: 37 additions & 6 deletions src/main/java/until/the/eternity/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
package until.the.eternity.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import until.the.eternity.common.filter.GatewayAuthFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {

private final GatewayAuthFilter gatewayAuthFilter;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(
authorize -> authorize.anyRequest().permitAll() // 모든 요청 허용
)
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.formLogin(form -> form.disable()) // 기본 로그인 폼 비활성화
.httpBasic(basic -> basic.disable()); // HTTP Basic 인증 비활성화
http.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
authorize ->
authorize
// Actuator 및 헬스체크 엔드포인트는 공개
.requestMatchers("/actuator/**", "/health")
.permitAll()
// Swagger 문서는 공개
.requestMatchers(
"/swagger-ui/**", "/v3/api-docs/**", "/docs/**")
.permitAll()
// API 엔드포인트는 공개
// TODO: API endpoint 정리 후 matcher 수정
// TODO: 권한 관련 기능 개발 완료 후 hasRole 추가
.requestMatchers("/api/**", "/auction-history/**")
.permitAll()
// 나머지 요청은 인증 필요
.anyRequest()
.authenticated())
.addFilterBefore(gatewayAuthFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
Expand Down
Loading