diff --git a/.gitignore b/.gitignore index c2065bc..9eb012d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### Security ### +src/main/resources/application.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index ca9420c..6ae85e1 100644 --- a/build.gradle +++ b/build.gradle @@ -29,11 +29,20 @@ dependencies { // implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // DB를 자바 코드로 다루게 해주는 도구 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' runtimeOnly 'com.mysql:mysql-connector-j' // MySQL과 연결해주는 드라이버 compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Spring Security (비밀번호 암호화 및 전역 보안 필터용) + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT 발급 및 검증을 위한 jjwt 라이브러리 (최신 0.12.x 버전 기준) + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index d645d14..f013b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: image: mysql:8.0 restart: always environment: - MYSQL_DATABASE: proovy_db + MYSQL_DATABASE: springboot_study MYSQL_ROOT_PASSWORD: root ports: - "3306:3306" \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/common/exception/GlobalExceptionHandler.java b/src/main/java/com/study/springbootstudy/common/exception/GlobalExceptionHandler.java index 25ba19e..a06aa32 100644 --- a/src/main/java/com/study/springbootstudy/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/study/springbootstudy/common/exception/GlobalExceptionHandler.java @@ -10,7 +10,7 @@ @RestControllerAdvice public class GlobalExceptionHandler { - // 1. 우리가 직접 던진 GeneralException 처리 + // 1. GeneralException 처리 @ExceptionHandler(GeneralException.class) public ApiResponse handleGeneralException(GeneralException e) { return ApiResponse.onFailure(e.getErrorCode(), e.getMessage(), null); diff --git a/src/main/java/com/study/springbootstudy/config/SecurityConfig.java b/src/main/java/com/study/springbootstudy/config/SecurityConfig.java new file mode 100644 index 0000000..872ad63 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/config/SecurityConfig.java @@ -0,0 +1,40 @@ +package com.study.springbootstudy.config; + +import com.study.springbootstudy.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() + .requestMatchers("/api/members/join", "/api/members/login", "/api/ping").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/config/SwaggerConfig.java b/src/main/java/com/study/springbootstudy/config/SwaggerConfig.java new file mode 100644 index 0000000..4ed9bb0 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/config/SwaggerConfig.java @@ -0,0 +1,34 @@ +package com.study.springbootstudy.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + String jwtSchemeName = "jwtAuth"; + + // API 요청 시 헤더에 토큰을 요구하도록 설정 + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + // 토큰 인증 방식 설정 (Bearer 형식) + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .info(new Info().title("스프링부트 API 문서").version("1.0.0")) + .addSecurityItem(securityRequirement) + .components(components); + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/controller/CommentController.java b/src/main/java/com/study/springbootstudy/controller/CommentController.java new file mode 100644 index 0000000..1fa20e1 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/controller/CommentController.java @@ -0,0 +1,33 @@ +package com.study.springbootstudy.controller; + +import com.study.springbootstudy.common.ApiResponse; +import com.study.springbootstudy.dto.CommentRequestDto; +import com.study.springbootstudy.service.CommentService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + // 댓글 생성 + @PostMapping("/api/posts/{postId}/comments") + public ApiResponse createComment( + @PathVariable Long postId, + @RequestBody @Valid CommentRequestDto request) { + Long commentId = commentService.createComment(postId, request); + return ApiResponse.onSuccess(commentId); + } + + // 댓글 삭제 + @DeleteMapping("/api/comments/{commentId}") + public ApiResponse deleteComment( + @PathVariable Long commentId, + @RequestParam String password) { + commentService.deleteComment(commentId, password); + return ApiResponse.onSuccess(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/controller/MemberController.java b/src/main/java/com/study/springbootstudy/controller/MemberController.java new file mode 100644 index 0000000..7ce0db6 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/controller/MemberController.java @@ -0,0 +1,36 @@ +package com.study.springbootstudy.controller; + +import com.study.springbootstudy.common.ApiResponse; +import com.study.springbootstudy.dto.MemberJoinRequestDto; +import com.study.springbootstudy.service.MemberService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.study.springbootstudy.dto.MemberLoginRequestDto; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/members") + +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/join") + public ApiResponse join(@RequestBody @Valid MemberJoinRequestDto request) { + Long memberId = memberService.join(request); + return ApiResponse.onSuccess(memberId); // 기존에 작성하신 ApiResponse 규격에 맞게 호출 + } + + @PostMapping("/login") + public ApiResponse login(@RequestBody @Valid MemberLoginRequestDto request) { + // 서비스에서 생성된 토큰 문자열을 그대로 반환받음 + String token = memberService.login(request); + + // 추가 DTO 없이 토큰 문자열 자체를 result에 담아 응답 + return ApiResponse.onSuccess(token); + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/controller/PostController.java b/src/main/java/com/study/springbootstudy/controller/PostController.java index a27ad37..086f811 100644 --- a/src/main/java/com/study/springbootstudy/controller/PostController.java +++ b/src/main/java/com/study/springbootstudy/controller/PostController.java @@ -8,24 +8,43 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.security.Principal; // 👈 시큐리티가 제공하는 인증 정보 객체 + @RestController -@RequiredArgsConstructor // Service를 주입받기 위해 꼭 필요합니다. -@RequestMapping("/api/posts") // 이 컨트롤러의 기본 주소를 설정합니다. +@RequiredArgsConstructor +@RequestMapping("/api/posts") public class PostController { private final PostService postService; - // 1. 게시글 생성 API + // [생성] Principal 객체를 통해 현재 로그인한 사용자의 이메일을 가져옴 @PostMapping - public ApiResponse createPost(@Valid @RequestBody PostRequestDto request) { - Long savedPostId = postService.createPost(request); - return ApiResponse.onSuccess(savedPostId); // 성공 시 저장된 게시글의 ID를 반환! + public ApiResponse createPost(@RequestBody @Valid PostRequestDto request, Principal principal) { + // principal.getName()을 호출하면, 필터에서 토큰을 쪼개 저장했던 '이메일'이 나온다 + Long postId = postService.createPost(request, principal.getName()); + return ApiResponse.onSuccess(postId); } - // 2. 게시글 상세 조회 API + // [조회] 단건 조회 (조회는 권한 검증 없이 누구나 볼 수 있도록 둔다.) @GetMapping("/{postId}") public ApiResponse getPost(@PathVariable Long postId) { PostResponseDto response = postService.getPostDetail(postId); - return ApiResponse.onSuccess(response); // 성공 시 게시글 상세 정보를 반환! + return ApiResponse.onSuccess(response); + } + + // [수정] URL 경로의 ID, 수정할 데이터, 그리고 로그인한 사용자(Principal)를 받는다. + @PutMapping("/{id}") + public ApiResponse updatePost(@PathVariable Long id, + @RequestBody @Valid PostRequestDto request, + Principal principal) { + PostResponseDto response = postService.updatePost(id, request, principal.getName()); + return ApiResponse.onSuccess(response); + } + + // [삭제] 기존의 @RequestParam String password를 삭제하고 Principal로 교체한다. + @DeleteMapping("/{id}") + public ApiResponse deletePost(@PathVariable Long id, Principal principal) { + postService.deletePost(id, principal.getName()); + return ApiResponse.onSuccess(null); // 삭제는 돌려줄 데이터가 없으므로 null 반환 } } \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/domain/Comment.java b/src/main/java/com/study/springbootstudy/domain/Comment.java new file mode 100644 index 0000000..4e59872 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/domain/Comment.java @@ -0,0 +1,34 @@ +package com.study.springbootstudy.domain; + +import com.study.springbootstudy.common.exception.GeneralException; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String content; + private String password; // 댓글 작성자 확인용 + + // N:1 관계 매핑 (게시글 하나에 여러 댓글). 지연 로딩으로 성능 최적화 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + // 권한 검증 로직 + public void validatePassword(String inputPassword) { + if (!this.password.equals(inputPassword)) { + throw new GeneralException("AUTH403", "비밀번호가 일치하지 않습니다. 수정/삭제 권한이 없습니다."); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/domain/Member.java b/src/main/java/com/study/springbootstudy/domain/Member.java new file mode 100644 index 0000000..8a84763 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/domain/Member.java @@ -0,0 +1,33 @@ +package com.study.springbootstudy.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // 무분별한 객체 생성을 막는 안전장치 +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // DB가 부여하는 고유 번호 (PK) + + @Column(nullable = false, unique = true) + private String email; // 로그인 아이디로 사용할 이메일 (중복 불가) + + @Column(nullable = false) + private String password; // 암호화되어 저장될 비밀번호 + + @Column(nullable = false) + private String name; // 사용자의 이름 또는 닉네임 + + @Builder + public Member(String email, String password, String name) { + this.email = email; + this.password = password; + this.name = name; + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/domain/Post.java b/src/main/java/com/study/springbootstudy/domain/Post.java index 5f5b7ea..20e1620 100644 --- a/src/main/java/com/study/springbootstudy/domain/Post.java +++ b/src/main/java/com/study/springbootstudy/domain/Post.java @@ -1,22 +1,48 @@ package com.study.springbootstudy.domain; +import com.study.springbootstudy.common.exception.GeneralException; import jakarta.persistence.*; -import lombok.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; -@Entity // 이 클래스가 DB의 테이블 역할을 한다는 선언 +@Entity @Getter -@Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor public class Post { - @Id // 기본키(PK)로 지정 - @GeneratedValue(strategy = GenerationType.IDENTITY) // ID를 1, 2, 3... 자동으로 늘려줌 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) // 빈 값을 허용하지 않음 + @Column(nullable = false) private String title; - @Column(columnDefinition = "TEXT", nullable = false) + @Column(nullable = false, length = 1000) private String content; + + // 회원(Member)과의 다대일 연관관계 설정 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + public Post(String title, String content, Member member) { + this.title = title; + this.content = content; + this.member = member; + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + // 작성자 본인인지 확인하는 검증 메서드로 변경 + public void validateAuthor(String currentUserEmail) { + if (!this.member.getEmail().equals(currentUserEmail)) { + throw new GeneralException("AUTH403", "해당 게시글에 대한 권한이 없습니다."); + } + } } \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/dto/CommentRequestDto.java b/src/main/java/com/study/springbootstudy/dto/CommentRequestDto.java new file mode 100644 index 0000000..c392496 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/dto/CommentRequestDto.java @@ -0,0 +1,13 @@ +package com.study.springbootstudy.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class CommentRequestDto { + @NotBlank(message = "댓글 내용은 필수입니다.") + private String content; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/dto/MemberJoinRequestDto.java b/src/main/java/com/study/springbootstudy/dto/MemberJoinRequestDto.java new file mode 100644 index 0000000..b6e783c --- /dev/null +++ b/src/main/java/com/study/springbootstudy/dto/MemberJoinRequestDto.java @@ -0,0 +1,21 @@ +package com.study.springbootstudy.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MemberJoinRequestDto { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; + + @NotBlank(message = "이름은 필수입니다.") + private String name; +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/dto/MemberLoginRequestDto.java b/src/main/java/com/study/springbootstudy/dto/MemberLoginRequestDto.java new file mode 100644 index 0000000..2a98203 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/dto/MemberLoginRequestDto.java @@ -0,0 +1,16 @@ +package com.study.springbootstudy.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MemberLoginRequestDto { + + @NotBlank(message = "이메일은 필수입니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/dto/PostRequestDto.java b/src/main/java/com/study/springbootstudy/dto/PostRequestDto.java index da1a056..c977433 100644 --- a/src/main/java/com/study/springbootstudy/dto/PostRequestDto.java +++ b/src/main/java/com/study/springbootstudy/dto/PostRequestDto.java @@ -3,16 +3,16 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Getter; -import lombok.Setter; +import lombok.NoArgsConstructor; @Getter -@Setter +@NoArgsConstructor public class PostRequestDto { - @NotBlank(message = "게시글 제목은 필수입니다.") + @NotBlank(message = "제목은 필수입니다.") private String title; - @NotBlank(message = "내용을 입력해주세요.") + @NotBlank(message = "내용은 필수입니다.") @Size(min = 10, message = "내용은 최소 10자 이상이어야 합니다.") private String content; } \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/repository/CommentRepository.java b/src/main/java/com/study/springbootstudy/repository/CommentRepository.java new file mode 100644 index 0000000..fa6e684 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package com.study.springbootstudy.repository; + +import com.study.springbootstudy.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/repository/MemberRepository.java b/src/main/java/com/study/springbootstudy/repository/MemberRepository.java new file mode 100644 index 0000000..d0c1b26 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package com.study.springbootstudy.repository; + +import com.study.springbootstudy.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + // 이메일(로그인 아이디)을 통해 회원 정보를 찾는 메서드 + Optional findByEmail(String email); + + // 중복 가입 방지를 위해 이메일 존재 여부를 확인하는 메서드 + boolean existsByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/security/JwtAuthenticationFilter.java b/src/main/java/com/study/springbootstudy/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a1475bf --- /dev/null +++ b/src/main/java/com/study/springbootstudy/security/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.study.springbootstudy.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 1. 요청 헤더에서 "Bearer [토큰]" 추출 + String token = resolveToken(request); + + // 2. 토큰이 존재하고 유효한지 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + // 3. 토큰에서 이메일을 꺼내어 스프링 시큐리티의 인증 객체로 등록 + String email = jwtTokenProvider.getEmailFromToken(token); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(email, null, Collections.emptyList()); + + // 4. 전역 보안 컨텍스트에 현재 로그인한 사용자 정보 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + // 5. 검증이 끝나면 다음 필터나 컨트롤러로 요청을 넘김 + filterChain.doFilter(request, response); + } + + // 헤더의 "Authorization" 키값에서 순수 토큰 문자열만 잘라내는 유틸리티 메서드 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/security/JwtTokenProvider.java b/src/main/java/com/study/springbootstudy/security/JwtTokenProvider.java new file mode 100644 index 0000000..53fbad1 --- /dev/null +++ b/src/main/java/com/study/springbootstudy/security/JwtTokenProvider.java @@ -0,0 +1,62 @@ +package com.study.springbootstudy.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private final SecretKey key; + private final long expirationTime; + + // application.yml의 jwt.secret과 jwt.access-expiration 값을 주입받음 + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, + @Value("${jwt.access-expiration}") long expirationTime) { + // Base64 문자열을 디코딩하여 암호화 알고리즘에 맞는 SecretKey 객체로 변환 + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.expirationTime = expirationTime; + } + + // 1. 토큰 생성 메서드 (로그인 성공 시 호출) + public String createToken(String email) { + Date now = new Date(); + Date validity = new Date(now.getTime() + expirationTime); + + return Jwts.builder() + .subject(email) // 토큰의 주체(subject)에 이메일 저장 + .issuedAt(now) // 발행 시간 + .expiration(validity) // 만료 시간 + .signWith(key) // SecretKey를 사용하여 서명 + .compact(); + } + + // 2. 토큰에서 이메일 추출 메서드 (인가 과정에서 호출) + public String getEmailFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(key) // 토큰 서명 검증 + .build() + .parseSignedClaims(token) + .getPayload(); // 페이로드(데이터) 추출 + return claims.getSubject(); // 저장해둔 이메일 반환 + } + + // 3. 토큰 유효성 검증 메서드 + public boolean validateToken(String token) { + try { + // 파싱 중 에러가 발생하지 않으면 유효한 토큰으로 간주 + Jwts.parser().verifyWith(key).build().parseSignedClaims(token); + return true; + } catch (Exception e) { + // 만료되었거나 변조된 토큰일 경우 false 반환 + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/service/CommentService.java b/src/main/java/com/study/springbootstudy/service/CommentService.java new file mode 100644 index 0000000..00a556f --- /dev/null +++ b/src/main/java/com/study/springbootstudy/service/CommentService.java @@ -0,0 +1,50 @@ +package com.study.springbootstudy.service; + +import com.study.springbootstudy.common.exception.GeneralException; +import com.study.springbootstudy.domain.Comment; +import com.study.springbootstudy.domain.Post; +import com.study.springbootstudy.dto.CommentRequestDto; +import com.study.springbootstudy.repository.CommentRepository; +import com.study.springbootstudy.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + // [댓글 생성] + @Transactional + public Long createComment(Long postId, CommentRequestDto request) { + // 1. 부모 게시글이 있는지 먼저 확인 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new GeneralException("POST404", "해당 게시글이 존재하지 않습니다.")); + + // 2. 댓글 생성 및 게시글과 연결 + Comment comment = Comment.builder() + .content(request.getContent()) + .password(request.getPassword()) + .post(post) // 핵심! 어떤 게시글의 댓글인지 매핑 + .build(); + + return commentRepository.save(comment).getId(); + } + + // [댓글 삭제] + @Transactional + public void deleteComment(Long commentId, String password) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GeneralException("COMMENT404", "해당 댓글이 존재하지 않습니다.")); + + // 1. 비밀번호 검증 + comment.validatePassword(password); + + // 2. 삭제 + commentRepository.delete(comment); + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/service/MemberService.java b/src/main/java/com/study/springbootstudy/service/MemberService.java new file mode 100644 index 0000000..977c33a --- /dev/null +++ b/src/main/java/com/study/springbootstudy/service/MemberService.java @@ -0,0 +1,54 @@ +package com.study.springbootstudy.service; + +import com.study.springbootstudy.common.exception.GeneralException; +import com.study.springbootstudy.domain.Member; +import com.study.springbootstudy.dto.MemberJoinRequestDto; +import com.study.springbootstudy.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.study.springbootstudy.security.JwtTokenProvider; +import com.study.springbootstudy.dto.MemberLoginRequestDto; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; // SecurityConfig에서 등록한 빈 주입 + private final JwtTokenProvider jwtTokenProvider; + + @Transactional + public Long join(MemberJoinRequestDto request) { + // 1. 이메일 중복 검증 + if (memberRepository.existsByEmail(request.getEmail())) { + throw new GeneralException("MEMBER409", "이미 존재하는 이메일입니다."); + } + + // 2. 비밀번호 암호화 및 엔티티 생성 + Member member = Member.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) // BCrypt 암호화 적용 + .name(request.getName()) + .build(); + + // 3. DB 영속화 + return memberRepository.save(member).getId(); + } + + public String login(MemberLoginRequestDto request) { + // 1. 이메일로 회원 조회 + Member member = memberRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new GeneralException("MEMBER404", "가입되지 않은 이메일입니다.")); + + // 2. 비밀번호 검증 (입력받은 평문 vs DB에 저장된 암호화 문자열) + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new GeneralException("AUTH401", "비밀번호가 일치하지 않습니다."); + } + + // 3. 인증 성공 시 JWT 토큰 생성 및 반환 + return jwtTokenProvider.createToken(member.getEmail()); + } +} \ No newline at end of file diff --git a/src/main/java/com/study/springbootstudy/service/PostService.java b/src/main/java/com/study/springbootstudy/service/PostService.java index b331031..57e644f 100644 --- a/src/main/java/com/study/springbootstudy/service/PostService.java +++ b/src/main/java/com/study/springbootstudy/service/PostService.java @@ -1,9 +1,11 @@ package com.study.springbootstudy.service; import com.study.springbootstudy.common.exception.GeneralException; +import com.study.springbootstudy.domain.Member; import com.study.springbootstudy.domain.Post; import com.study.springbootstudy.dto.PostRequestDto; import com.study.springbootstudy.dto.PostResponseDto; +import com.study.springbootstudy.repository.MemberRepository; import com.study.springbootstudy.repository.PostRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -11,29 +13,61 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) // 기본적으로 읽기 전용으로 설정하여 성능을 높인다. +@Transactional(readOnly = true) public class PostService { private final PostRepository postRepository; + private final MemberRepository memberRepository; // 회원 정보를 DB에서 찾기 위해 추가 - @Transactional // 데이터를 수정, 저장할 때는 이 어노테이션이 꼭 필요하다. - public Long createPost(PostRequestDto request) { - // 1. DTO를 Entity(Post)로 변환 + // [생성] 게시글 작성 시, 현재 로그인한 사용자의 이메일을 받아 작성자로 매핑 + @Transactional + public Long createPost(PostRequestDto request, String email) { + // 1. 이메일로 현재 로그인한 회원(Member) 엔티티 조회 + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new GeneralException("MEMBER404", "존재하지 않는 회원입니다.")); + + // 2. DTO를 Entity로 변환 (비밀번호 삭제, member 객체 주입) Post post = Post.builder() .title(request.getTitle()) .content(request.getContent()) + .member(member) // 👈 외래키(member_id) 연결! .build(); - // 2. DB에 저장하고, 저장된 게시글의 번호(ID)를 반환 return postRepository.save(post).getId(); } + // [조회] 단건 조회 (변경 없음) public PostResponseDto getPostDetail(Long postId) { - // 1. DB에서 ID로 게시글을 찾고, 없으면 예외를 띄움 Post post = postRepository.findById(postId) .orElseThrow(() -> new GeneralException("POST404", "해당 게시글이 존재하지 않습니다.")); + return PostResponseDto.from(post); + } + + // [수정] 수정 시, 현재 로그인한 이메일을 넘겨받아 작성자 본인인지 확인 + @Transactional + public PostResponseDto updatePost(Long postId, PostRequestDto request, String email) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new GeneralException("POST404", "해당 게시글이 존재하지 않습니다.")); + + // 1. 작성자 검증 (비밀번호 대신 토큰 이메일 사용) + post.validateAuthor(email); + + // 2. 수정 로직 수행 + post.update(request.getTitle(), request.getContent()); - // 2. 찾은 게시글을 DTO에 담아 컨트롤러로 보냄 return PostResponseDto.from(post); } + + // [삭제] 삭제 시, 현재 로그인한 이메일을 넘겨받아 작성자 본인인지 확인 + @Transactional + public void deletePost(Long postId, String email) { // password 파라미터 삭제, email 추가 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new GeneralException("POST404", "해당 게시글이 존재하지 않습니다.")); + + // 1. 작성자 검증 + post.validateAuthor(email); + + // 2. 삭제 + postRepository.delete(post); + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5c64e40..c2e3a26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,10 @@ +jwt: + secret: KtW5EIXy36L1i3u4qd6Py6Yx353WJiUYlLNR31IJgugph09XSiv/Cs5UFYv+av1A0sWBBQEqRr81wGpMoUBdhg== + access-expiration: 3600000 + spring: datasource: - url: jdbc:mysql://localhost:3306/proovy_db?serverTimezone=Asia/Seoul + url: jdbc:mysql://localhost:3306/springboot_study?serverTimezone=Asia/Seoul username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/test.http b/test.http index 9e2fe7c..dfcebff 100644 --- a/test.http +++ b/test.http @@ -4,10 +4,10 @@ POST http://localhost:8080/api/posts Content-Type: application/json { - "title": "첫 DB 저장 테스트!", - "content": "프로비 프로젝트 화이팅! 10글자 넘기기." + "title": "스프링부트 첫 DB", + "content": "10글자 채우기ㅣㅣㅣㅣㅣㅣ" } ### 2. 게시글 조회 (GET) -GET http://localhost:8080/api/posts/999 \ No newline at end of file +GET http://localhost:8080/api/posts/1 \ No newline at end of file