diff --git a/src/main/java/com/nova/nova_server/domain/ai/controller/AiTestController.java b/src/main/java/com/nova/nova_server/domain/ai/controller/AiTestController.java index d1077b7..9be8196 100644 --- a/src/main/java/com/nova/nova_server/domain/ai/controller/AiTestController.java +++ b/src/main/java/com/nova/nova_server/domain/ai/controller/AiTestController.java @@ -15,7 +15,7 @@ @Profile("local") @RestController @RequiredArgsConstructor -@RequestMapping("/ai/test") +@RequestMapping("/debug/ai/test") public class AiTestController { private final AiBatchService aiBatchService; diff --git a/src/main/java/com/nova/nova_server/domain/auth/controller/AuthController.java b/src/main/java/com/nova/nova_server/domain/auth/controller/AuthController.java index 4a0f735..7450aa2 100644 --- a/src/main/java/com/nova/nova_server/domain/auth/controller/AuthController.java +++ b/src/main/java/com/nova/nova_server/domain/auth/controller/AuthController.java @@ -3,9 +3,8 @@ import com.nova.nova_server.domain.auth.dto.AuthResponse; import com.nova.nova_server.domain.auth.error.AuthErrorCode; import com.nova.nova_server.domain.auth.util.JwtUtil; +import com.nova.nova_server.domain.member.dto.MemberRequestDto; import com.nova.nova_server.domain.member.dto.MemberResponseDto; -import com.nova.nova_server.domain.member.entity.Member; -import com.nova.nova_server.domain.member.repository.MemberRepository; import com.nova.nova_server.domain.member.service.MemberService; import com.nova.nova_server.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -73,6 +72,19 @@ public ApiResponse createMockUser() { return ApiResponse.success(member); } + @Operation( + summary = "관리자 계정 생성", + description = "관리자 계정이 없을 때만 생성합니다. 소셜 계정은 연결되지 않습니다." + ) + @PostMapping("/create-admin") + @Transactional + public ApiResponse createAdmin( + @RequestBody MemberRequestDto requestDto + ) { + MemberResponseDto member = memberService.createAdminMember(requestDto); + return ApiResponse.success(member); + } + @Operation( summary = "테스트 토큰 발급", description = "요청한 사용자 ID로 7일 유효 테스트용 JWT를 발급합니다." @@ -96,4 +108,23 @@ public ApiResponse generateTestToken( .name(member.getName()) .build()); } + + @Operation( + summary = "관리자 토큰 발급", + description = "등록된 관리자 계정으로 7일 유효 JWT를 발급합니다." + ) + @PostMapping("/generate-admin-token") + public ApiResponse generateAdminToken() { + MemberResponseDto admin = memberService.getAdminMemberInfo(); + + long sevenDaysInMillis = 7L * 24 * 60 * 60 * 1000; + String token = jwtUtil.generateToken(admin.getId(), sevenDaysInMillis); + + return ApiResponse.success(AuthResponse.builder() + .accessToken(token) + .memberId(admin.getId()) + .email(admin.getEmail()) + .name(admin.getName()) + .build()); + } } diff --git a/src/main/java/com/nova/nova_server/domain/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/nova/nova_server/domain/auth/filter/JwtAuthenticationFilter.java index be8ec54..7233ed8 100644 --- a/src/main/java/com/nova/nova_server/domain/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/nova/nova_server/domain/auth/filter/JwtAuthenticationFilter.java @@ -1,5 +1,7 @@ package com.nova.nova_server.domain.auth.filter; +import com.nova.nova_server.domain.member.entity.Member; +import com.nova.nova_server.domain.member.repository.MemberRepository; import com.nova.nova_server.domain.auth.util.JwtUtil; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -12,6 +14,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.lang.NonNull; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -24,23 +27,27 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; + private final MemberRepository memberRepository; private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; @Override protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain ) throws ServletException, IOException { String token = resolveToken(request); if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { try { Long memberId = jwtUtil.getMemberIdFromToken(token); + if (memberId == null) { + filterChain.doFilter(request, response); + return; + } - // memberId만 Principal로 설정 (DB 조회 없음) Authentication authentication = createAuthentication(memberId); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { @@ -59,11 +66,15 @@ private String resolveToken(HttpServletRequest request) { return null; } - private Authentication createAuthentication(Long memberId) { + private Authentication createAuthentication(@NonNull Long memberId) { + Member.MemberRole role = memberRepository.findById(memberId) + .map(member -> member.getRole() != null ? member.getRole() : Member.MemberRole.USER) + .orElse(Member.MemberRole.USER); + return new UsernamePasswordAuthenticationToken( memberId, null, - Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role.name())) ); } } diff --git a/src/main/java/com/nova/nova_server/domain/member/controller/MemberInfoController.java b/src/main/java/com/nova/nova_server/domain/member/controller/MemberInfoController.java index fce8fbf..bb3e4e9 100644 --- a/src/main/java/com/nova/nova_server/domain/member/controller/MemberInfoController.java +++ b/src/main/java/com/nova/nova_server/domain/member/controller/MemberInfoController.java @@ -39,7 +39,7 @@ public ApiResponse deleteMember( @PathVariable("member_id") Long memberId, @AuthenticationPrincipal Long authenticatedMemberId) { - memberService.deleteMember(memberId); + memberService.deleteMember(memberId, authenticatedMemberId); return ApiResponse.success(null); } @@ -70,9 +70,10 @@ public ApiResponse getPersonalization( public ApiResponse updatePersonalization( @Parameter(description = "사용자 ID") @PathVariable Long memberId, + @AuthenticationPrincipal Long authenticatedMemberId, @RequestBody MemberPersonalizationDto request) { - memberService.updateMemberPersonalization(memberId, request); + memberService.updateMemberPersonalization(memberId, authenticatedMemberId, request); return ApiResponse.success(null); } diff --git a/src/main/java/com/nova/nova_server/domain/member/controller/MemberProfileImageController.java b/src/main/java/com/nova/nova_server/domain/member/controller/MemberProfileImageController.java index c4d807c..0150275 100644 --- a/src/main/java/com/nova/nova_server/domain/member/controller/MemberProfileImageController.java +++ b/src/main/java/com/nova/nova_server/domain/member/controller/MemberProfileImageController.java @@ -5,11 +5,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -32,13 +32,14 @@ public class MemberProfileImageController { public ApiResponse uploadProfileImage( @Parameter(description = "이미지를 업로드할 사용자의 ID", example = "1") @PathVariable Long memberId, + @AuthenticationPrincipal Long authenticatedMemberId, @Parameter( description = "업로드할 이미지 파일 (MultipartFile)", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) ) @RequestParam("file") MultipartFile file) throws IOException { - memberProfileImageService.uploadProfileImageRaw(memberId, file); + memberProfileImageService.uploadProfileImageRaw(memberId, authenticatedMemberId, file); return ApiResponse.success(null); } @@ -65,9 +66,10 @@ public ResponseEntity getProfileImage( @DeleteMapping("/{memberId}/profile-image") public ApiResponse deleteProfileImage( @Parameter(description = "이미지를 삭제할 사용자의 ID", example = "1") - @PathVariable Long memberId) { + @PathVariable Long memberId, + @AuthenticationPrincipal Long authenticatedMemberId) { - memberProfileImageService.deleteProfileImage(memberId); + memberProfileImageService.deleteProfileImage(memberId, authenticatedMemberId); return ApiResponse.success(null); } diff --git a/src/main/java/com/nova/nova_server/domain/member/dto/MemberResponseDto.java b/src/main/java/com/nova/nova_server/domain/member/dto/MemberResponseDto.java index 3edcb63..d8a7e69 100644 --- a/src/main/java/com/nova/nova_server/domain/member/dto/MemberResponseDto.java +++ b/src/main/java/com/nova/nova_server/domain/member/dto/MemberResponseDto.java @@ -15,4 +15,5 @@ public class MemberResponseDto { private String name; private String email; private String profileImage; + private Member.MemberRole role; } \ No newline at end of file diff --git a/src/main/java/com/nova/nova_server/domain/member/entity/Member.java b/src/main/java/com/nova/nova_server/domain/member/entity/Member.java index 3158207..2cf06bc 100644 --- a/src/main/java/com/nova/nova_server/domain/member/entity/Member.java +++ b/src/main/java/com/nova/nova_server/domain/member/entity/Member.java @@ -4,13 +4,9 @@ import com.nova.nova_server.domain.cardNews.entity.CardNewsBookmark; import com.nova.nova_server.domain.cardNews.entity.CardNewsRelevance; import com.nova.nova_server.domain.common.BaseEntity; -import com.nova.nova_server.domain.member.dto.MemberConnectedAccountsResponseDto; -import com.nova.nova_server.global.apiPayload.ApiResponse; import com.nova.nova_server.global.apiPayload.exception.NovaException; import jakarta.persistence.*; import lombok.*; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import java.util.ArrayList; import java.util.List; @@ -40,6 +36,12 @@ public class Member extends BaseEntity { @Column(nullable = false, length = 100) private String name; + @Setter + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MemberRole role = MemberRole.USER; + @Column(nullable = true, unique = false, length = 255) private String email; @@ -75,6 +77,11 @@ public enum MemberLevel { ADVANCED // 숙련자 } + public enum MemberRole { + USER, + ADMIN + } + public void connectGoogle(String googleId) { this.googleId = googleId; } diff --git a/src/main/java/com/nova/nova_server/domain/member/error/MemberErrorCode.java b/src/main/java/com/nova/nova_server/domain/member/error/MemberErrorCode.java index 1ebb836..e62aab1 100644 --- a/src/main/java/com/nova/nova_server/domain/member/error/MemberErrorCode.java +++ b/src/main/java/com/nova/nova_server/domain/member/error/MemberErrorCode.java @@ -10,8 +10,12 @@ public enum MemberErrorCode implements ErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4040", "존재하지 않는 사용자입니다."), + ADMIN_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4041", "관리자 계정이 존재하지 않습니다."), + ADMIN_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER4090", "이미 관리자 계정이 존재합니다."), MEMBER_READ_FORBIDDEN(HttpStatus.FORBIDDEN, "MEMBER4030", "본인의 정보만 조회할 수 있습니다."), MEMBER_FORBIDDEN(HttpStatus.FORBIDDEN, "MEMBER4030", "본인의 정보만 수정할 수 있습니다."), + MEMBER_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "MEMBER4031", "일반 사용자는 본인 계정만 탈퇴할 수 있습니다."), + ADMIN_DELETE_ADMIN_FORBIDDEN(HttpStatus.FORBIDDEN, "MEMBER4032", "관리자 계정은 탈퇴시킬 수 없습니다."), MEMBER_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "MEMBER4000", "이름은 필수 입력 항목입니다."); private final HttpStatus httpStatus; diff --git a/src/main/java/com/nova/nova_server/domain/member/repository/MemberRepository.java b/src/main/java/com/nova/nova_server/domain/member/repository/MemberRepository.java index 028a493..08136da 100644 --- a/src/main/java/com/nova/nova_server/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/nova/nova_server/domain/member/repository/MemberRepository.java @@ -13,4 +13,6 @@ public interface MemberRepository extends JpaRepository { boolean existsByKakaoId(String kakaoId); Optional findByGithubId(String githubId); boolean existsByGithubId(String githubId); + boolean existsByRole(Member.MemberRole role); + Optional findByRole(Member.MemberRole role); } diff --git a/src/main/java/com/nova/nova_server/domain/member/service/MemberProfileImageService.java b/src/main/java/com/nova/nova_server/domain/member/service/MemberProfileImageService.java index e4ad587..1cecd99 100644 --- a/src/main/java/com/nova/nova_server/domain/member/service/MemberProfileImageService.java +++ b/src/main/java/com/nova/nova_server/domain/member/service/MemberProfileImageService.java @@ -1,6 +1,5 @@ package com.nova.nova_server.domain.member.service; -import com.nova.nova_server.domain.member.dto.MemberResponseDto; import com.nova.nova_server.domain.member.entity.Member; import com.nova.nova_server.domain.member.entity.MemberProfileImage; import com.nova.nova_server.domain.member.error.MemberErrorCode; @@ -10,7 +9,6 @@ import com.nova.nova_server.global.apiPayload.exception.NovaException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -27,10 +25,15 @@ public class MemberProfileImageService { private final MemberProfileImageRepository memberProfileImageRepository; @Transactional - public void uploadProfileImageRaw(Long memberId, MultipartFile file) throws IOException { + public void uploadProfileImageRaw(Long memberId, Long authenticatedMemberId, MultipartFile file) throws IOException { + Member authenticatedMember = memberRepository.findById(authenticatedMemberId) + .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + Member member = memberRepository.findById(memberId) .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + validateProfileImagePermission(authenticatedMember, member); + log.info("Original size: {} bytes", file.getSize()); byte[] compressedImage = imageProcessor.compressImage(file); @@ -51,11 +54,28 @@ public Optional getProfileImageRaw(Long memberId) { } @Transactional - public void deleteProfileImage(Long memberId) { + public void deleteProfileImage(Long memberId, Long authenticatedMemberId) { + Member authenticatedMember = memberRepository.findById(authenticatedMemberId) + .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + + Member targetMember = memberRepository.findById(memberId) + .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + + validateProfileImagePermission(authenticatedMember, targetMember); + if (!memberProfileImageRepository.existsById(memberId)) { throw new NovaException(MemberErrorCode.MEMBER_NOT_FOUND); } memberProfileImageRepository.deleteById(memberId); } + + private void validateProfileImagePermission(Member authenticatedMember, Member targetMember) { + boolean isAdmin = authenticatedMember.getRole() == Member.MemberRole.ADMIN; + boolean isSelf = authenticatedMember.getId().equals(targetMember.getId()); + + if (!isAdmin && !isSelf) { + throw new NovaException(MemberErrorCode.MEMBER_FORBIDDEN); + } + } } diff --git a/src/main/java/com/nova/nova_server/domain/member/service/MemberService.java b/src/main/java/com/nova/nova_server/domain/member/service/MemberService.java index b7ae1cc..26ebd82 100644 --- a/src/main/java/com/nova/nova_server/domain/member/service/MemberService.java +++ b/src/main/java/com/nova/nova_server/domain/member/service/MemberService.java @@ -55,6 +55,7 @@ public MemberResponseDto getMemberInfo(Long memberId) { .name(member.getName()) .email(member.getEmail()) .profileImage(profileImageUrl) + .role(member.getRole()) .build(); } @@ -99,7 +100,15 @@ public MemberPersonalizationDto getMemberPersonalization(Long memberId) { } @Transactional - public void updateMemberPersonalization(Long memberId, MemberPersonalizationDto memberPersonalizationDto) { + public void updateMemberPersonalization(Long memberId, Long authenticatedMemberId, MemberPersonalizationDto memberPersonalizationDto) { + Member authenticatedMember = memberRepository.findById(authenticatedMemberId) + .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + + boolean isAdmin = authenticatedMember.getRole() == Member.MemberRole.ADMIN; + if (!isAdmin && !memberId.equals(authenticatedMemberId)) { + throw new NovaException(MemberErrorCode.MEMBER_FORBIDDEN); + } + Member member = memberRepository.findById(memberId) .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); @@ -168,19 +177,34 @@ private void updateMemberKeywords(Member member, List keywordNames) { memberPreferKeywordRepository.saveAll(preferKeywords); } - public void deleteMember(Long memberId) { - Member member = memberRepository.findById(memberId) + public void deleteMember(Long memberId, Long authenticatedMemberId) { + Member authenticatedMember = memberRepository.findById(authenticatedMemberId) + .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + + Member targetMember = memberRepository.findById(memberId) .orElseThrow(() -> new NovaException(MemberErrorCode.MEMBER_NOT_FOUND)); + boolean isAdmin = authenticatedMember.getRole() == Member.MemberRole.ADMIN; + + // 일반 유저는 본인 계정만 탈퇴 가능 + if (!isAdmin && !memberId.equals(authenticatedMemberId)) { + throw new NovaException(MemberErrorCode.MEMBER_DELETE_FORBIDDEN); + } + + // 관리자는 관리자 계정을 탈퇴시킬 수 없음(본인 포함) + if (isAdmin && targetMember.getRole() == Member.MemberRole.ADMIN) { + throw new NovaException(MemberErrorCode.ADMIN_DELETE_ADMIN_FORBIDDEN); + } + // Delete all related entities (children first due to FK constraints) memberProfileImageRepository.deleteById(memberId); - memberPreferKeywordRepository.deleteByMember(member); - memberPreferInterestRepository.deleteByMember(member); + memberPreferKeywordRepository.deleteByMember(targetMember); + memberPreferInterestRepository.deleteByMember(targetMember); cardNewsBookmarkRepository.deleteAllByMemberId(memberId); cardNewsRelevanceRepository.deleteAllByMemberId(memberId); cardNewsHiddenRepository.deleteAllByMemberId(memberId); - memberRepository.delete(member); + memberRepository.delete(targetMember); } @Transactional(readOnly = true) @@ -210,4 +234,30 @@ public MemberResponseDto createTestMember() { Member savedUser = memberRepository.save(mockUser); return getMemberInfo(savedUser.getId()); } + + @Transactional + public MemberResponseDto createAdminMember(MemberRequestDto requestDto) { + if (memberRepository.existsByRole(Member.MemberRole.ADMIN)) { + throw new NovaException(MemberErrorCode.ADMIN_ALREADY_EXISTS); + } + + if (requestDto.getName() == null || requestDto.getName().trim().isEmpty()) { + throw new NovaException(MemberErrorCode.MEMBER_NAME_REQUIRED); + } + + Member admin = Member.builder() + .name(requestDto.getName()) + .role(Member.MemberRole.ADMIN) + .build(); + + Member savedAdmin = memberRepository.save(admin); + return getMemberInfo(savedAdmin.getId()); + } + + @Transactional(readOnly = true) + public MemberResponseDto getAdminMemberInfo() { + Member admin = memberRepository.findByRole(Member.MemberRole.ADMIN) + .orElseThrow(() -> new NovaException(MemberErrorCode.ADMIN_NOT_FOUND)); + return getMemberInfo(admin.getId()); + } } \ No newline at end of file diff --git a/src/main/java/com/nova/nova_server/global/config/SecurityConfig.java b/src/main/java/com/nova/nova_server/global/config/SecurityConfig.java index 8d9eb6d..153af58 100644 --- a/src/main/java/com/nova/nova_server/global/config/SecurityConfig.java +++ b/src/main/java/com/nova/nova_server/global/config/SecurityConfig.java @@ -26,18 +26,25 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private static final String[] PERMIT_GET_PATTERNS = { - "/api/members/*/profile-image" + "/api/members/*/profile-image", + "/api/keywords" }; private static final String[] PERMIT_ALL_PATTERNS = { "/auth/**", - "/api/keywords", "/actuator/**", "/swagger-ui/**", - "/v3/api-docs/**", + "/v3/api-docs/**" + }; + + private static final String[] ADMIN_ALL_PATTERNS = { "/debug/**" }; + private static final String[] ADMIN_KEYWORD_PATTERNS = { + "/api/keywords/**" + }; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -46,6 +53,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.GET, PERMIT_GET_PATTERNS).permitAll() + .requestMatchers(HttpMethod.POST, ADMIN_KEYWORD_PATTERNS).hasRole("ADMIN") + .requestMatchers(HttpMethod.PUT, ADMIN_KEYWORD_PATTERNS).hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, ADMIN_KEYWORD_PATTERNS).hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, ADMIN_KEYWORD_PATTERNS).hasRole("ADMIN") + .requestMatchers(ADMIN_ALL_PATTERNS).hasRole("ADMIN") .requestMatchers(PERMIT_ALL_PATTERNS).permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/nova/nova_server/global/config/SwaggerConfig.java b/src/main/java/com/nova/nova_server/global/config/SwaggerConfig.java index 395930a..bc6e981 100644 --- a/src/main/java/com/nova/nova_server/global/config/SwaggerConfig.java +++ b/src/main/java/com/nova/nova_server/global/config/SwaggerConfig.java @@ -32,8 +32,7 @@ public OpenAPI openAPI() { .title("NOVA Server API") .description(""" NOVA 프로젝트 백엔드 API 문서입니다. - - 본 문서는 NOVA 서버에서 제공하는 REST API 명세를 정의합니다. - - Authorization: Bearer {JWT} 형식으로 인증합니다. + 테스트 JWT 는 /auth/generate-admin-token 로 발급받을 수 있습니다. 어드민 계정으로 모든 엔드포인트에 접근 가능합니다. """) .version("v1.0.0") );