diff --git a/backend/build.gradle b/backend/build.gradle index 509bd0a..673a51b 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation("com.google.code.gson:gson:2.10.1") diff --git a/backend/src/main/java/com/example/ecommercewebservice/domain/admin/controller/AdminController.java b/backend/src/main/java/com/example/ecommercewebservice/domain/admin/controller/AdminController.java new file mode 100644 index 0000000..811cd4e --- /dev/null +++ b/backend/src/main/java/com/example/ecommercewebservice/domain/admin/controller/AdminController.java @@ -0,0 +1,46 @@ +package com.example.ecommercewebservice.domain.admin.controller; + +import com.example.ecommercewebservice.domain.user.dto.UserRoleUpdateRequest; +import com.example.ecommercewebservice.domain.user.service.UserService; +import com.example.ecommercewebservice.global.security.annotation.RoleRequired; +import com.example.ecommercewebservice.domain.user.entity.UserRole; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +@RoleRequired(UserRole.ADMIN) +public class AdminController { + + private final UserService userService; + + /** + * 사용자 역할 변경 API + * 관리자만 접근 가능 + * + * @param userId 변경할 사용자 ID + * @param request 역할 변경 요청 + * @return 성공/실패 응답 + */ + @PutMapping("/users/{userId}/role") + public ResponseEntity updateUserRole( + @PathVariable Long userId, + @RequestBody UserRoleUpdateRequest request) { + userService.updateUserRole(userId, request.getRole()); + return ResponseEntity.ok().build(); + } + + /** + * 관리자 대시보드 API + * 관리자만 접근 가능 + * + * @return 대시보드 데이터 + */ + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + // TODO: 대시보드 데이터 조회 로직 구현 + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/ecommercewebservice/domain/user/dto/UserRoleUpdateRequest.java b/backend/src/main/java/com/example/ecommercewebservice/domain/user/dto/UserRoleUpdateRequest.java new file mode 100644 index 0000000..69407e4 --- /dev/null +++ b/backend/src/main/java/com/example/ecommercewebservice/domain/user/dto/UserRoleUpdateRequest.java @@ -0,0 +1,11 @@ +package com.example.ecommercewebservice.domain.user.dto; + +import com.example.ecommercewebservice.domain.user.entity.UserRole; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserRoleUpdateRequest { + private UserRole role; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserService.java b/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserService.java index 4ff301a..20cd3ce 100644 --- a/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserService.java +++ b/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserService.java @@ -3,7 +3,9 @@ import com.example.ecommercewebservice.domain.user.dto.LoginRequest; import com.example.ecommercewebservice.domain.user.dto.LoginResponse; import com.example.ecommercewebservice.domain.user.dto.SignupRequest; +import com.example.ecommercewebservice.domain.user.dto.UserRoleUpdateRequest; import com.example.ecommercewebservice.domain.user.entity.User; +import com.example.ecommercewebservice.domain.user.entity.UserRole; public interface UserService { /** @@ -29,4 +31,12 @@ public interface UserService { */ void logout(String token); + /** + * 사용자 역할 변경 + * 관리자만 호출 가능 + * + * @param userId 변경할 사용자 ID + * @param role 새로운 역할 + */ + void updateUserRole(Long userId, UserRole role); } diff --git a/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserServiceImpl.java b/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserServiceImpl.java index ef7f5a9..75ae8ea 100644 --- a/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserServiceImpl.java +++ b/backend/src/main/java/com/example/ecommercewebservice/domain/user/service/UserServiceImpl.java @@ -117,4 +117,14 @@ public void logout(String token) { throw new BusinessException(ErrorCode.INVALID_TOKEN); } } + + @Override + @Transactional + public void updateUserRole(Long userId, UserRole role) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + userId)); + + user.getRoles().clear(); + user.getRoles().add(role.getRole()); + } } diff --git a/backend/src/main/java/com/example/ecommercewebservice/global/config/redis/RedisConfig.java b/backend/src/main/java/com/example/ecommercewebservice/global/config/redis/RedisConfig.java index 8b7ae20..05ae04c 100644 --- a/backend/src/main/java/com/example/ecommercewebservice/global/config/redis/RedisConfig.java +++ b/backend/src/main/java/com/example/ecommercewebservice/global/config/redis/RedisConfig.java @@ -43,4 +43,4 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } -} \ No newline at end of file +}// \ No newline at end of file diff --git a/backend/src/main/java/com/example/ecommercewebservice/global/security/annotation/RoleRequired.java b/backend/src/main/java/com/example/ecommercewebservice/global/security/annotation/RoleRequired.java new file mode 100644 index 0000000..f51c490 --- /dev/null +++ b/backend/src/main/java/com/example/ecommercewebservice/global/security/annotation/RoleRequired.java @@ -0,0 +1,14 @@ +package com.example.ecommercewebservice.global.security.annotation; + +import com.example.ecommercewebservice.domain.user.entity.UserRole; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RoleRequired { + UserRole[] value() default {UserRole.USER}; +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/ecommercewebservice/global/security/aspect/RoleRequiredAspect.java b/backend/src/main/java/com/example/ecommercewebservice/global/security/aspect/RoleRequiredAspect.java new file mode 100644 index 0000000..35d632b --- /dev/null +++ b/backend/src/main/java/com/example/ecommercewebservice/global/security/aspect/RoleRequiredAspect.java @@ -0,0 +1,86 @@ +package com.example.ecommercewebservice.global.security.aspect; + +import com.example.ecommercewebservice.domain.user.entity.UserRole; +import com.example.ecommercewebservice.global.security.annotation.RoleRequired; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.Arrays; + +/** + * RoleRequired 어노테이션을 처리하는 AOP 구현체 + * 어노테이션이 적용된 메서드가 호출될 때 권한 검사를 수행 + */ +@Slf4j +@Aspect +@Component +public class RoleRequiredAspect { + + /** + * RoleRequired 어노테이션이 적용된 메서드 호출을 가로채서 권한 검사 + * + * @param joinPoint 가로챈 메서드 실행 지점 + * @param roleRequired 메서드에 적용된 RoleRequired 어노테이션 + * @return 원본 메서드의 실행 결과 + * @throws Throwable 메서드 실행 중 발생한 예외 또는 권한 부족 시 AccessDeniedException + */ + @Around("@annotation(roleRequired) || @within(roleRequired)") + public Object checkRole(ProceedingJoinPoint joinPoint, RoleRequired roleRequired) throws Throwable { + // 메서드 레벨 어노테이션이 없으면 클래스 레벨 어노테이션 확인 + if (roleRequired == null) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + + // 메서드에 어노테이션이 있는지 확인 + roleRequired = method.getAnnotation(RoleRequired.class); + + // 메서드에 없으면 클래스에서 확인 + if (roleRequired == null) { + roleRequired = method.getDeclaringClass().getAnnotation(RoleRequired.class); + } + } + + // 어노테이션이 없으면 그냥 진행 + if (roleRequired == null) { + return joinPoint.proceed(); + } + + // 현재 인증 정보 가져오기 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + log.warn("인증되지 않은 사용자의 보호된 메서드 접근 시도: {}", joinPoint.getSignature()); + throw new AccessDeniedException("인증되지 않은 사용자입니다."); + } + + UserRole[] requiredRoles = roleRequired.value(); + log.debug("권한 검사 시작: 필요한 권한={}, 메서드={}", + Arrays.toString(requiredRoles), joinPoint.getSignature()); + + // 필요한 권한 중 하나라도 있는지 확인 + boolean hasRequiredRole = Arrays.stream(requiredRoles) + .anyMatch(role -> authentication.getAuthorities().contains( + new SimpleGrantedAuthority(role.getRole()))); + + if (!hasRequiredRole) { + log.warn("권한 부족: 사용자={}, 필요한 권한={}, 메서드={}", + authentication.getName(), Arrays.toString(requiredRoles), joinPoint.getSignature()); + throw new AccessDeniedException("접근 권한이 없습니다. 필요한 권한: " + Arrays.toString(requiredRoles)); + } + + log.debug("권한 검사 통과: 사용자={}, 메서드={}", + authentication.getName(), joinPoint.getSignature()); + + // 권한 검사 통과 시 원래 메서드 실행 + return joinPoint.proceed(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/example/ecommercewebservice/global/util/JwtTokenProvider.java b/backend/src/main/java/com/example/ecommercewebservice/global/util/JwtTokenProvider.java index d3da790..fdd5804 100644 --- a/backend/src/main/java/com/example/ecommercewebservice/global/util/JwtTokenProvider.java +++ b/backend/src/main/java/com/example/ecommercewebservice/global/util/JwtTokenProvider.java @@ -20,7 +20,9 @@ import java.util.Arrays; import java.util.Collection; import java.util.Date; +import java.util.List; import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; /** * JWT 토큰의 생성, 검증, 파싱 등을 담당하는 유틸리티 클래스 @@ -34,6 +36,8 @@ public class JwtTokenProvider { private long tokenValidityInMilliseconds; private String issuer; private TokenRepository tokenRepository; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; /** * 기본 생성자 @@ -170,4 +174,35 @@ public boolean validateToken(String token) { public void invalidateToken(String token) { tokenRepository.invalidateToken(token); } + + /** + * HTTP 요청에서 JWT 토큰을 추출 + * + * @param request HTTP 요청 + * @return 추출된 JWT 토큰 또는 null + */ + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } + + /** + * JWT 토큰에서 사용자 역할을 추출 + * + * @param token JWT 토큰 + * @return 사용자 역할 목록 + */ + public List getRoles(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + String authorities = claims.get("auth", String.class); + return Arrays.asList(authorities.split(",")); + } } \ No newline at end of file diff --git a/backend/src/test/java/com/example/ecommercewebservice/domain/user/controller/UserControllerTest.java b/backend/src/test/java/com/example/ecommercewebservice/domain/user/controller/UserControllerTest.java new file mode 100644 index 0000000..9fd599e --- /dev/null +++ b/backend/src/test/java/com/example/ecommercewebservice/domain/user/controller/UserControllerTest.java @@ -0,0 +1,153 @@ +package com.example.ecommercewebservice.domain.user.controller; + +import com.example.ecommercewebservice.domain.user.dto.LoginRequest; +import com.example.ecommercewebservice.domain.user.dto.SignupRequest; +import com.example.ecommercewebservice.domain.user.entity.User; +import com.example.ecommercewebservice.domain.user.entity.UserRole; +import com.example.ecommercewebservice.domain.user.repository.UserRepository; +import com.example.ecommercewebservice.domain.user.service.UserService; +import com.example.ecommercewebservice.global.util.JwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + private String testToken; + + @BeforeEach + void setUp() { + // 테스트용 사용자 생성 + User testUser = User.builder() + .email("test@example.com") + .password(passwordEncoder.encode("Test1234!")) + .username("testuser") + .roles(Collections.singletonList(UserRole.USER.getRole())) + .build(); + userRepository.save(testUser); + + // 테스트용 인증 객체 생성 + List authorities = Collections.singletonList( + new SimpleGrantedAuthority(UserRole.USER.getRole()) + ); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + testUser.getEmail(), + testUser.getPassword(), + authorities + ); + + // 테스트용 토큰 생성 + testToken = jwtTokenProvider.createToken(authentication); + } + + @Test + @DisplayName("회원가입 성공 테스트") + void signup_success() throws Exception { + // given + SignupRequest signupRequest = new SignupRequest(); + signupRequest.setEmail("newuser@example.com"); + signupRequest.setPassword("NewPass123!"); + signupRequest.setUsername("newuser"); + signupRequest.setPhoneNumber("010-1234-5678"); + signupRequest.setAddress("서울시 강남구"); + + // when + ResultActions result = mockMvc.perform(post("/api/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(signupRequest))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.msg").value("회원가입이 완료되었습니다.")) + .andExpect(jsonPath("$.data.email").value("newuser@example.com")) + .andExpect(jsonPath("$.data.username").value("newuser")); + } + + @Test + @DisplayName("로그인 성공 테스트") + void login_success() throws Exception { + // given + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail("test@example.com"); + loginRequest.setPassword("Test1234!"); + + // when + ResultActions result = mockMvc.perform(post("/api/users/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.msg").value("로그인이 완료되었습니다.")) + .andExpect(jsonPath("$.data.email").value("test@example.com")) + .andExpect(jsonPath("$.data.username").value("testuser")) + .andExpect(jsonPath("$.data.accessToken").exists()); + } + + @Test + @DisplayName("로그아웃 성공 테스트") + void logout_success() throws Exception { + // when + ResultActions result = mockMvc.perform(post("/api/users/logout") + .header("Authorization", "Bearer " + testToken)); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); + } + + @Test + @DisplayName("인증되지 않은 사용자의 접근 테스트") + void unauthorized_access() throws Exception { + // when + ResultActions result = mockMvc.perform(post("/api/users/logout") + .header("Authorization", "Bearer invalid_token")); + + // then + result.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(401)) + .andExpect(jsonPath("$.error").value("Unauthorized")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다. 로그인 후 다시 시도해주세요.")) + .andExpect(jsonPath("$.path").value("/api/users/logout")); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..642c198 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' # Docker Compose 파일의 버전 설정 + +networks: # 네트워크 정의 + monitor: # 'monitor'라는 이름의 네트워크 생성 + driver: bridge # 기본 브리지 네트워크 드라이버 사용 + +services: # 서비스 정의 + redis: # Redis 서비스 설정 + container_name: redis # 컨테이너 이름 설정 + image: redis:7.4 # Redis 이미지 버전 7.4 사용 + ports: + - "6379:6379" # 호스트와 컨테이너 간 포트 매핑 (6379 포트) + command: ["redis-server", "--requirepass", "pk2258"] # 비밀번호 설정 + networks: + - monitor # 'monitor' 네트워크에 연결 + restart: always # 컨테이너가 종료되면 자동으로 재시작