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
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
import java.io.IOException;
import java.util.List;

import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.opensource.docgrid.global.common.response.ErrorResponse;
import com.opensource.docgrid.global.exception.ErrorCode;

import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
Expand All @@ -17,13 +23,21 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* HTTP JWT를 인증하고 요청별 현재 권한을 SecurityContext에 넣는다.
*
* <p>관리자 요청은 Redis 권한 캐시를 신뢰하지 않고 primary를 직접 확인한다.
* 확인할 수 없으면 이전 ADMIN 권한으로 진행시키지 않고 503을 반환한다.
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;
private final TokenBlacklistService tokenBlacklistService;
private final RoleAuthorityService roleAuthorityService;
private final ObjectMapper objectMapper;
private final RequestMatcher adminRequests;

@Override
protected void doFilterInternal(HttpServletRequest request,
Expand All @@ -36,7 +50,25 @@ protected void doFilterInternal(HttpServletRequest request,
if (claims != null && !isBlacklisted(claims.get("jti", String.class))) {
Long userId = claims.get("userId", Long.class);
String email = claims.getSubject();
List<String> roles = roleAuthorityService.getRoles(userId);
// 1. 관리자 경로는 매번 primary에서 검증하고 일반 경로만 Redis 역할 캐시를 사용한다.
boolean adminPath = adminRequests.matches(request);
List<String> roles;
try {
roles = adminPath ? roleAuthorityService.getRolesForAdmin(userId)
: roleAuthorityService.getRoles(userId);
} catch (RuntimeException e) {
if (!adminPath) {
throw e;
}
// 2. primary 확인이 불가능하면 캐시된 ADMIN으로 통과시키지 않는다.
log.error("관리자 권한 primary 검증 실패: {}", e.getClass().getSimpleName());
ErrorCode errorCode = ErrorCode.ADMIN_ROLE_UNAVAILABLE;
response.setStatus(errorCode.getHttpStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode, request));
return;
}

List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,49 @@
import java.util.List;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import com.opensource.docgrid.domain.auth.service.query.PrimaryRoleQueryService;
import com.opensource.docgrid.domain.user.repository.UserRoleRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* 인가(hasRole) 판단에 쓰는 사용자 role을 JWT가 아니라 DB에서 매 요청 조회한다.
* 인가(hasRole) 판단에 쓰는 사용자 role을 JWT에 고정하지 않고 DB를 기준으로 관리한다.
*
* <p>JWT에 role을 박제하면 관리자가 role을 부여/회수해도 재로그인 전까지 반영되지 않는다.
* DB 조회 부하를 줄이기 위해 Redis에 짧은 TTL로 캐싱하고, role 변경 시 즉시 무효화한다.
* 일반 요청의 DB 조회 부하를 줄이기 위해 Redis에 짧은 TTL로 캐싱한다. HTTP 관리자
* 인가는 캐시를 우회해 primary를 매번 검증하며, role 변경 시 캐시 세대를 올린다.
*
* <p>이 서비스는 인증 필터(모든 요청)의 critical path에 있으므로, {@code TokenBlacklistService}와
* 동일하게 Redis 장애 시 예외를 전파하지 않고 DB 조회로 폴백한다 — Redis가 죽었다고 전체 API가
* 막히면 안 된다.
* <p>일반 요청은 Redis 장애 시 기존과 같이 DB 조회로 폴백한다. 관리자 HTTP 요청은
* 가용성보다 최신 권한을 우선하며 primary 검증 실패를 호출자에게 전파한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RoleAuthorityService {

private static final String KEY_PREFIX = "auth:roles:";
private static final String EPOCH_PREFIX = "auth:roles:epoch:";
private static final Duration TTL = Duration.ofSeconds(30);
private static final RedisScript<Long> INVALIDATE = new DefaultRedisScript<>("""
redis.call('INCR', KEYS[2])
redis.call('DEL', KEYS[1])
return 1
""", Long.class);
private static final RedisScript<Long> CACHE_IF_UNCHANGED = new DefaultRedisScript<>("""
local current = redis.call('GET', KEYS[2]) or '0'
if current ~= ARGV[1] then return 0 end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
""", Long.class);

private final StringRedisTemplate redisTemplate;
private final UserRoleRepository userRoleRepository;
private final PrimaryRoleQueryService primaryRoleQueryService;

public List<String> getRoles(Long userId) {
// 1. 먼저 Redis 캐시를 확인한다 — 대부분의 요청은 여기서 끝나 DB 부하를 줄인다.
Expand All @@ -40,17 +56,30 @@ public List<String> getRoles(Long userId) {
return cached.isBlank() ? List.of() : Arrays.asList(cached.split(","));
}

// 2. 캐시 미스면 DB에서 최신 role을 조회한다(source of truth).
// 2. 조회 전 세대를 기억해 회수·부여가 DB 조회와 캐시 저장 사이에 끼어드는지 확인한다.
String epoch = readEpoch(userId);
List<String> roles = userRoleRepository.findRoleCodesByUserId(userId);

// 3. 다음 요청부터는 캐시로 처리되도록 짧은 TTL로 저장해둔다.
writeCache(userId, roles);
// 3. 세대가 바뀌면 조회 결과를 반환하거나 재캐시하지 않는다.
if (epoch != null && !writeCacheIfUnchanged(userId, epoch, roles)) {
return List.of();
}
return roles;
}

public List<String> getRolesForAdmin(Long userId) {
// 관리자 인가는 Redis 상태와 복제 지연에 관계없이 현재 primary만 신뢰한다.
return primaryRoleQueryService.findCurrentRoles(userId);
}

public void invalidate(Long userId) {
try {
redisTemplate.delete(KEY_PREFIX + userId);
// 세대 증가와 삭제가 원자적이어야 이전 DB 읽기가 삭제 뒤 캐시를 부활시키지 못한다.
Long invalidated = redisTemplate.execute(INVALIDATE,
List.of(KEY_PREFIX + userId, EPOCH_PREFIX + userId));
if (!Long.valueOf(1).equals(invalidated)) {
log.error("Redis role 캐시 무효화 결과를 확인할 수 없습니다. userId={}", userId);
}
} catch (Exception e) {
log.error("Redis role 캐시 무효화 실패, userId={}: {}", userId, e.getMessage());
}
Expand All @@ -65,11 +94,25 @@ private String readCache(Long userId) {
}
}

private void writeCache(Long userId, List<String> roles) {
private String readEpoch(Long userId) {
try {
String epoch = redisTemplate.opsForValue().get(EPOCH_PREFIX + userId);
return epoch == null ? "0" : epoch;
} catch (Exception e) {
log.error("Redis role 캐시 세대 조회 실패, DB로 폴백합니다. userId={}: {}", userId, e.getMessage());
return null;
}
}

private boolean writeCacheIfUnchanged(Long userId, String epoch, List<String> roles) {
try {
redisTemplate.opsForValue().set(KEY_PREFIX + userId, String.join(",", roles), TTL);
Long saved = redisTemplate.execute(CACHE_IF_UNCHANGED,
List.of(KEY_PREFIX + userId, EPOCH_PREFIX + userId), epoch,
String.join(",", roles), String.valueOf(TTL.toSeconds()));
return Long.valueOf(1).equals(saved);
} catch (Exception e) {
log.error("Redis role 캐시 저장 실패, userId={}: {}", userId, e.getMessage());
log.error("Redis role 캐시 저장 실패, DB 조회 결과를 사용합니다. userId={}: {}", userId, e.getMessage());
return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.opensource.docgrid.domain.auth.service.query;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.opensource.docgrid.domain.user.repository.UserRoleRepository;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;

/**
* HTTP 관리자 인가에 필요한 역할을 Redis나 standby 없이 현재 primary에서 확인한다.
*
* <p>호출자의 read-only 트랜잭션을 상속하지 않으며, OpenProxy가 잘못 라우팅하면
* 권한을 추정하지 않고 요청을 실패시킨다. 일반 API·WebSocket의 역할 캐시는 담당하지 않는다.
*/
@Service
@RequiredArgsConstructor
public class PrimaryRoleQueryService {

private final EntityManager entityManager;
private final UserRoleRepository userRoleRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public List<String> findCurrentRoles(Long userId) {
// 1. 명시적 read-write 트랜잭션 안에서 OpenProxy의 실제 도착 역할을 확인한다.
Object inRecovery = entityManager.createNativeQuery("SELECT pg_is_in_recovery()")
.getSingleResult();
if (!Boolean.FALSE.equals(inRecovery)) {
throw new IllegalStateException("Primary role verification is unavailable");
}

// 2. 같은 트랜잭션의 최신 primary에서 역할을 조회한다.
return userRoleRepository.findRoleCodesByUserId(userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ public UserRoleResponse revokeRole(Long targetUserId, String roleCode) {
return UserRoleResponse.of(targetUser, roles);
}

// DB 커밋 전에 캐시를 지우면, 커밋 직전 시점에 캐시 미스가 난 다른 요청이 아직 커밋 안 된(옛날) role을
// 다시 캐시에 채워 넣을 수 있다. 그래서 무효화는 반드시 트랜잭션 커밋 이후로 미룬다.
// DB 커밋 전에 캐시를 지우면, 다른 요청이 아직 커밋 안 된 역할을 다시 읽을 수 있다.
// 커밋 후 무효화하고, 조회·저장 사이에 끼어드는 요청은 Redis 세대 비교로 재캐시를 막는다.
// 트랜잭션 밖에서 호출되는 경우(예: 단위 테스트)는 즉시 무효화한다.
private void invalidateAfterCommit(Long userId) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,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.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfigurationSource;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.opensource.docgrid.domain.auth.jwt.JwtAuthenticationFilter;
import com.opensource.docgrid.domain.auth.jwt.JwtProvider;
import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService;
Expand Down Expand Up @@ -42,6 +45,7 @@ public class SecurityConfig {
private final McpAccessTokenCommandService mcpAccessTokenCommandService;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final RestAccessDeniedHandler restAccessDeniedHandler;
private final ObjectMapper objectMapper;

/**
* MCP({@code /mcp})와 웹 API({@code /mcp/tokens} 포함)를 포함한 전체 보안 필터 체인을 구성한다.
Expand All @@ -54,6 +58,8 @@ public class SecurityConfig {
@Bean
@Order(2)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 인가 규칙과 JWT 필터가 동일한 관리자 경로 판정을 사용해야 캐시 우회 틈이 없다.
RequestMatcher adminRequests = PathPatternRequestMatcher.withDefaults().matcher("/admin/**");
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
Expand All @@ -69,7 +75,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 2. STOMP CONNECT — StompAuthChannelInterceptor가 JWT 검증
// 3. STOMP SUBSCRIBE·SEND — StompDestinationAuthorizationInterceptor가 목적지별 권한 검증
.requestMatchers("/ws/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(adminRequests).hasRole("ADMIN")
.anyRequest().authenticated()
)
// 인증 실패와 권한 부족을 상태 코드로 구분하고, 본문 없는 기본 응답 대신 공통 ErrorResponse를 준다.
Expand All @@ -82,7 +88,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
* "A를 B보다 앞자리에 꽂아라"는 뜻이라 위치 기준점(앵커)으로만 재사용한다 — 이 필터 앞에 꽂아야
* 두 인증 필터가 authorizeHttpRequests의 최종 인가 판정보다 먼저 실행돼 SecurityContext를 채울 수 있다.
*/
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService, roleAuthorityService), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService,
roleAuthorityService, objectMapper, adminRequests), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new McpApiKeyAuthFilter(mcpAccessTokenCommandService), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public enum ErrorCode {
PERMISSION_DENIED(HttpStatus.FORBIDDEN, "ROLE-002", "접근 권한이 없습니다."),
ROLE_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "ROLE-003", "이미 부여된 역할입니다."),
ROLE_NOT_ASSIGNED(HttpStatus.NOT_FOUND, "ROLE-004", "부여되지 않은 역할입니다."),
ADMIN_ROLE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ROLE-005", "관리자 권한을 확인할 수 없습니다."),

// COLLECTION
COLLECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "COLLECTION-001", "컬렉션을 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

import java.util.List;

Expand All @@ -17,6 +18,9 @@
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;

import com.fasterxml.jackson.databind.ObjectMapper;

@ExtendWith(MockitoExtension.class)
@DisplayName("JwtAuthenticationFilter 단위 테스트")
Expand All @@ -36,7 +40,9 @@ class JwtAuthenticationFilterTest {
@BeforeEach
void setUp() {
jwtProvider = new JwtProvider(TEST_SECRET, 3600L);
filter = new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService, roleAuthorityService);
filter = new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService,
roleAuthorityService, new ObjectMapper().findAndRegisterModules(),
PathPatternRequestMatcher.withDefaults().matcher("/admin/**"));
SecurityContextHolder.clearContext();
}

Expand Down Expand Up @@ -80,9 +86,49 @@ void doFilter_authenticates_whenBlacklistCheckFails() throws Exception {
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
}

@Test
@DisplayName("관리자 요청은 Redis의 오래된 ADMIN 대신 primary의 현재 역할을 사용한다")
void doFilter_adminRequestUsesCurrentPrimaryRoles() throws Exception {
String token = jwtProvider.generateToken(1L, "user@test.com");
given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false);
given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("USER"));

filter.doFilter(adminRequestWithToken(token), new MockHttpServletResponse(), new MockFilterChain());

assertThat(SecurityContextHolder.getContext().getAuthentication().getAuthorities())
.extracting("authority").containsExactly("ROLE_USER");
then(roleAuthorityService).should().getRolesForAdmin(1L);
then(roleAuthorityService).shouldHaveNoMoreInteractions();
}

@Test
@DisplayName("primary 역할 검증 실패 시 관리자 요청을 503으로 거부한다")
void doFilter_adminRequestFailsClosedWhenPrimaryUnavailable() throws Exception {
String token = jwtProvider.generateToken(1L, "user@test.com");
given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false);
given(roleAuthorityService.getRolesForAdmin(1L))
.willThrow(new IllegalStateException("primary unavailable"));
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();

filter.doFilter(adminRequestWithToken(token), response, chain);

assertThat(response.getStatus()).isEqualTo(503);
assertThat(response.getContentAsString()).contains("ROLE-005");
assertThat(chain.getRequest()).isNull();
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
}

private MockHttpServletRequest requestWithToken(String token) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer " + token);
return request;
}

private MockHttpServletRequest adminRequestWithToken(String token) {
MockHttpServletRequest request = requestWithToken(token);
request.setRequestURI("/admin/workers");
request.setServletPath("/admin/workers");
return request;
}
}
Loading
Loading