Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/

### Security ###
src/main/resources/application.yml
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@RestControllerAdvice
public class GlobalExceptionHandler {

// 1. 우리가 직접 던진 GeneralException 처리
// 1. GeneralException 처리
@ExceptionHandler(GeneralException.class)
public ApiResponse<String> handleGeneralException(GeneralException e) {
return ApiResponse.onFailure(e.getErrorCode(), e.getMessage(), null);
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/com/study/springbootstudy/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
34 changes: 34 additions & 0 deletions src/main/java/com/study/springbootstudy/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> createComment(
@PathVariable Long postId,
@RequestBody @Valid CommentRequestDto request) {
Long commentId = commentService.createComment(postId, request);
return ApiResponse.onSuccess(commentId);
}

// 댓글 삭제
@DeleteMapping("/api/comments/{commentId}")
public ApiResponse<Void> deleteComment(
@PathVariable Long commentId,
@RequestParam String password) {
commentService.deleteComment(commentId, password);
return ApiResponse.onSuccess(null);
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> join(@RequestBody @Valid MemberJoinRequestDto request) {
Long memberId = memberService.join(request);
return ApiResponse.onSuccess(memberId); // 기존에 작성하신 ApiResponse 규격에 맞게 호출
}

@PostMapping("/login")
public ApiResponse<String> login(@RequestBody @Valid MemberLoginRequestDto request) {
// 서비스에서 생성된 토큰 문자열을 그대로 반환받음
String token = memberService.login(request);

// 추가 DTO 없이 토큰 문자열 자체를 result에 담아 응답
return ApiResponse.onSuccess(token);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> createPost(@Valid @RequestBody PostRequestDto request) {
Long savedPostId = postService.createPost(request);
return ApiResponse.onSuccess(savedPostId); // 성공 시 저장된 게시글의 ID를 반환!
public ApiResponse<Long> 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<PostResponseDto> getPost(@PathVariable Long postId) {
PostResponseDto response = postService.getPostDetail(postId);
return ApiResponse.onSuccess(response); // 성공 시 게시글 상세 정보를 반환!
return ApiResponse.onSuccess(response);
}

// [수정] URL 경로의 ID, 수정할 데이터, 그리고 로그인한 사용자(Principal)를 받는다.
@PutMapping("/{id}")
public ApiResponse<PostResponseDto> 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<Void> deletePost(@PathVariable Long id, Principal principal) {
postService.deletePost(id, principal.getName());
return ApiResponse.onSuccess(null); // 삭제는 돌려줄 데이터가 없으므로 null 반환
}
}
34 changes: 34 additions & 0 deletions src/main/java/com/study/springbootstudy/domain/Comment.java
Original file line number Diff line number Diff line change
@@ -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", "비밀번호가 일치하지 않습니다. 수정/삭제 권한이 없습니다.");
}
}
}
33 changes: 33 additions & 0 deletions src/main/java/com/study/springbootstudy/domain/Member.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
42 changes: 34 additions & 8 deletions src/main/java/com/study/springbootstudy/domain/Post.java
Original file line number Diff line number Diff line change
@@ -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", "해당 게시글에 대한 권한이 없습니다.");
}
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/study/springbootstudy/dto/CommentRequestDto.java
Original file line number Diff line number Diff line change
@@ -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;
}
Loading