diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/RedisAuthorizationManager.java b/src/main/java/com/kamco/cd/kamcoback/auth/RedisAuthorizationManager.java new file mode 100644 index 00000000..9fcb3b16 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/RedisAuthorizationManager.java @@ -0,0 +1,188 @@ +package com.kamco.cd.kamcoback.auth; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kamco.cd.kamcoback.common.enums.RoleType; +import com.kamco.cd.kamcoback.menu.dto.MenuDto; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.stereotype.Component; + +/** redis에 등록된 메뉴별 권한 확인 */ +@Component +@RequiredArgsConstructor +public class RedisAuthorizationManager + implements AuthorizationManager { + + private static final Logger log = LogManager.getLogger(RedisAuthorizationManager.class); + private static final String REDIS_KEY = "auth:api:role"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public AuthorizationDecision check( + Supplier authenticationSupplier, RequestAuthorizationContext context) { + + HttpServletRequest request = context.getRequest(); + String requestPath = normalizePath(request.getRequestURI()); + + Authentication authentication = authenticationSupplier.get(); + if (authentication == null || !authentication.isAuthenticated()) { + return new AuthorizationDecision(false); + } + + // ADMIN 은 무조건 허용 + if (hasAdmin(authentication)) { + return new AuthorizationDecision(true); + } + + // 사용자 role 목록 + List userRoles = extractRoles(authentication); + if (userRoles.isEmpty()) { + return new AuthorizationDecision(false); + } + + // Redis 조회 (1회) + String json = redisTemplate.opsForValue().get(REDIS_KEY); + if (json == null || json.isBlank()) { + return new AuthorizationDecision(false); + } + + // JSON 파싱 (1회) + List menus = parseMenus(json); + if (menus.isEmpty()) { + return new AuthorizationDecision(false); + } + + // role + prefix URI 매칭 + for (String role : userRoles) { + if (isAllowed(menus, role, requestPath)) { + return new AuthorizationDecision(true); + } + } + + return new AuthorizationDecision(false); + } + + /* ========================= + * 핵심 권한 판별 로직 + * ========================= */ + private boolean isAllowed(List menus, String role, String requestPath) { + + for (MenuDto.MenuWithRolesDto menu : menus) { + if (menu == null) { + continue; + } + + String baseUri = menu.getMenuApiUri(); + if (baseUri == null || baseUri.isBlank()) { + continue; + } + + // role 포함 여부 + if (!hasRole(menu.getRoles(), role)) { + continue; + } + + // prefix URI 허용 + if (matchUri(baseUri, requestPath)) { + return true; + } + } + return false; + } + + /* ========================= + * URI prefix 매칭 + * ========================= */ + private boolean matchUri(String baseUri, String requestPath) { + String base = normalizePath(baseUri); + String req = normalizePath(requestPath); + + // /api/log/audit → /api/log/audit/** + return req.equals(base) || req.startsWith(base + "/"); + } + + /* ========================= + * role 문자열 처리 + * ========================= */ + private boolean hasRole(String rolesCsv, String targetRole) { + if (rolesCsv == null || rolesCsv.isBlank()) { + return false; + } + if (targetRole == null || targetRole.isBlank()) { + return false; + } + + for (String r : rolesCsv.split(",")) { + if (targetRole.equalsIgnoreCase(r.trim())) { + return true; + } + } + return false; + } + + /* ========================= + * Redis JSON 파싱 + * ========================= */ + private List parseMenus(String json) { + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse redis menu json. key={}", REDIS_KEY, e); + return Collections.emptyList(); + } + } + + /* ========================= + * ADMIN 판별 + * ========================= */ + private boolean hasAdmin(Authentication authentication) { + for (GrantedAuthority ga : authentication.getAuthorities()) { + if (ga == null || ga.getAuthority() == null) { + continue; + } + + String auth = ga.getAuthority(); + String admin = RoleType.ADMIN.getId(); + + if (auth.equals(admin) || auth.equals("ROLE_" + admin)) { + return true; + } + } + return false; + } + + /* ========================= + * ROLE 목록 추출 + * ========================= */ + private List extractRoles(Authentication authentication) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .filter(a -> a != null && !a.isBlank()) + .map(a -> a.startsWith("ROLE_") ? a.substring(5) : a) + .toList(); + } + + /* ========================= + * URI 정규화 + * ========================= */ + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + return "/"; + } + return path.replaceAll("//+", "/"); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java b/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java index 3a34cce4..a2be68ad 100644 --- a/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java +++ b/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java @@ -2,6 +2,7 @@ package com.kamco.cd.kamcoback.config; import com.kamco.cd.kamcoback.auth.CustomAuthenticationProvider; import com.kamco.cd.kamcoback.auth.JwtAuthenticationFilter; +import com.kamco.cd.kamcoback.auth.RedisAuthorizationManager; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -11,6 +12,7 @@ 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.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -27,16 +29,17 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomAuthenticationProvider customAuthenticationProvider; + private final RedisAuthorizationManager redisAuthorizationManager; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(csrf -> csrf.disable()) + .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(form -> form.disable()) - .httpBasic(basic -> basic.disable()) - .logout(logout -> logout.disable()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) .authenticationProvider(customAuthenticationProvider) .authorizeHttpRequests( auth -> @@ -74,10 +77,10 @@ public class SecurityConfig { "/api/auth/logout", "/swagger-ui/**", "/api/members/*/password", - "/api/code/type/**", "/v3/api-docs/**") .permitAll() .anyRequest() + // .access(redisAuthorizationManager) .authenticated()) .addFilterBefore( jwtAuthenticationFilter, diff --git a/src/main/java/com/kamco/cd/kamcoback/menu/MenuApiController.java b/src/main/java/com/kamco/cd/kamcoback/menu/MenuApiController.java index 93dd3dfe..e6df3b23 100644 --- a/src/main/java/com/kamco/cd/kamcoback/menu/MenuApiController.java +++ b/src/main/java/com/kamco/cd/kamcoback/menu/MenuApiController.java @@ -85,12 +85,12 @@ public class MenuApiController { @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) }) @PostMapping("/auth") - public ApiResponseDto getFindByRoleRedis() { + public ApiResponseDto getFindByRoleRedis() { menuService.getFindByRoleRedis(); - return ApiResponseDto.createOK(null); + return ApiResponseDto.createOK("ok"); } - @Operation(summary = "권한별 메뉴 조회", description = "권한별 메뉴 조회") + @Operation(summary = "권한별 메뉴 조회", description = "로그인 권한별 메뉴 목록") @ApiResponses( value = { @ApiResponse( diff --git a/src/main/java/com/kamco/cd/kamcoback/menu/dto/MenuDto.java b/src/main/java/com/kamco/cd/kamcoback/menu/dto/MenuDto.java index 445a1684..e331b3df 100644 --- a/src/main/java/com/kamco/cd/kamcoback/menu/dto/MenuDto.java +++ b/src/main/java/com/kamco/cd/kamcoback/menu/dto/MenuDto.java @@ -4,6 +4,7 @@ import com.kamco.cd.kamcoback.common.utils.interfaces.JsonFormatDttm; import io.swagger.v3.oas.annotations.media.Schema; import java.time.ZonedDateTime; import java.util.List; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -61,4 +62,15 @@ public class MenuDto { this.menuApiUrl = menuApiUrl; } } + + @Getter + @AllArgsConstructor + public static class MenuWithRolesDto { + + private String menuUid; + private String menuNm; + private String menuUrl; + private String menuApiUri; + private String roles; // "ROLE_A,ROLE_B" + } } diff --git a/src/main/java/com/kamco/cd/kamcoback/menu/service/MenuService.java b/src/main/java/com/kamco/cd/kamcoback/menu/service/MenuService.java index c817f0e3..bee0a81f 100644 --- a/src/main/java/com/kamco/cd/kamcoback/menu/service/MenuService.java +++ b/src/main/java/com/kamco/cd/kamcoback/menu/service/MenuService.java @@ -46,6 +46,16 @@ public class MenuService { throw new RuntimeException(e); } } + + List menusWithRoles = menuCoreService.getMenuWithRoles(); + + try { + String key = "auth:api:role"; + String value = objectMapper.writeValueAsString(menusWithRoles); + redisTemplate.opsForValue().set(key, value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } /** diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/core/MenuCoreService.java b/src/main/java/com/kamco/cd/kamcoback/postgres/core/MenuCoreService.java index 5e3a7f23..d01054e0 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/core/MenuCoreService.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/core/MenuCoreService.java @@ -26,4 +26,13 @@ public class MenuCoreService { public List getFindByRole(String role) { return menuRepository.getFindByRole(role).stream().map(MenuEntity::toDto).toList(); } + + /** + * 메뉴별 권한 조회 + * + * @return + */ + public List getMenuWithRoles() { + return menuRepository.getFindByMenuWithRoles(); + } } diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/entity/YearEntity.java b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/YearEntity.java new file mode 100644 index 00000000..4378e78d --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/YearEntity.java @@ -0,0 +1,24 @@ +package com.kamco.cd.kamcoback.postgres.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "tb_year") +public class YearEntity { + + @Id + @Column(name = "yyyy", nullable = false) + private Integer yyyy; + + @Size(max = 20) + @Column(name = "status", length = 20) + private String status; +} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryCustom.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryCustom.java index d9731225..d0f8a9b7 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryCustom.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryCustom.java @@ -1,5 +1,6 @@ package com.kamco.cd.kamcoback.postgres.repository.menu; +import com.kamco.cd.kamcoback.menu.dto.MenuDto.MenuWithRolesDto; import com.kamco.cd.kamcoback.postgres.entity.MenuEntity; import java.util.List; @@ -14,4 +15,11 @@ public interface MenuRepositoryCustom { * @return */ List getFindByRole(String role); + + /** + * 메뉴별 권한 조회 + * + * @return + */ + List getFindByMenuWithRoles(); } diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryImpl.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryImpl.java index 22754008..a995e7d7 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryImpl.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/menu/MenuRepositoryImpl.java @@ -3,10 +3,13 @@ package com.kamco.cd.kamcoback.postgres.repository.menu; import static com.kamco.cd.kamcoback.postgres.entity.QMenuEntity.menuEntity; import static com.kamco.cd.kamcoback.postgres.entity.QMenuMappEntity.menuMappEntity; +import com.kamco.cd.kamcoback.menu.dto.MenuDto.MenuWithRolesDto; import com.kamco.cd.kamcoback.postgres.entity.MenuEntity; import com.kamco.cd.kamcoback.postgres.entity.QMenuEntity; +import com.kamco.cd.kamcoback.postgres.entity.QMenuMappEntity; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,7 +20,6 @@ import org.springframework.stereotype.Repository; public class MenuRepositoryImpl implements MenuRepositoryCustom { private final JPAQueryFactory queryFactory; - private final StringExpression NULL_STRING = Expressions.stringTemplate("cast(null as text)"); @Override public List getFindAll() { @@ -57,4 +59,31 @@ public class MenuRepositoryImpl implements MenuRepositoryCustom { return content; } + + @Override + public List getFindByMenuWithRoles() { + QMenuEntity tm = menuEntity; + QMenuMappEntity tmm = menuMappEntity; + + Expression roleAgg = + Expressions.stringTemplate("string_agg({0}, {1})", tmm.roleCode, Expressions.constant(",")); + + List content = + queryFactory + .select( + Projections.constructor( + MenuWithRolesDto.class, + tm.menuUid, + tm.menuNm, + tm.menuUrl, + tm.menuApiUri, + roleAgg)) + .from(tm) + .leftJoin(tmm) + .on(tmm.menuUid.eq(tm).and(tmm.deleted.isFalse())) + .where(tm.deleted.isFalse()) + .groupBy(tm.menuUid, tm.menuNm, tm.menuUrl, tm.menuApiUri) + .fetch(); + return content; + } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..0a37bd4a --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + + + ${LOG_PATH}/application.log + + + + ${LOG_PATH}/application.%d{yyyy-MM-dd}.log + + 30 + + + + ${LOG_PATTERN} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +