diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..5fcc6fa
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index aa4549a..ffa83b0 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -1,5 +1,6 @@
+
\ No newline at end of file
diff --git a/.idea/modules/blog_manage.main.iml b/.idea/modules/blog_manage.main.iml
new file mode 100644
index 0000000..7d1eb3a
--- /dev/null
+++ b/.idea/modules/blog_manage.main.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/blog_manage/build.gradle b/blog_manage/build.gradle
index bd3e871..91f0f7d 100644
--- a/blog_manage/build.gradle
+++ b/blog_manage/build.gradle
@@ -1,8 +1,7 @@
plugins {
id 'java'
- id 'org.springframework.boot' version '3.5.6'
- id 'io.spring.dependency-management' version '1.1.7'
- id 'org.cyclonedx.bom' version '2.3.0'
+ id 'org.springframework.boot' version '3.3.5'
+ id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.leets.backend'
@@ -20,20 +19,31 @@ repositories {
}
dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ // Spring Boot 기본
implementation 'org.springframework.boot:spring-boot-starter-web'
- implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
- implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
- runtimeOnly 'com.h2database:h2'
+ // DB
+ runtimeOnly 'com.h2database:h2' // 테스트용
+ runtimeOnly 'mysql:mysql-connector-java:8.0.33' // 실제 배포용
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
- testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ // JWT
+ implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
- implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- runtimeOnly 'mysql:mysql-connector-java:8.0.33'
+ // OpenAPI (Swagger UI)
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
+
+ // 설정 메타데이터
+ annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+
+ // 테스트
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/common/ApiResponse.java b/blog_manage/src/main/java/com/leets/backend/blog/common/ApiResponse.java
index 5a88ebb..9cce265 100644
--- a/blog_manage/src/main/java/com/leets/backend/blog/common/ApiResponse.java
+++ b/blog_manage/src/main/java/com/leets/backend/blog/common/ApiResponse.java
@@ -28,16 +28,17 @@ public static ApiResponse onFailure(HttpStatus status, String message, T
return response;
}
- // Getter 추가 (JSON 직렬화용)
- public int getStatus() {
- return status;
+ // 데이터가 없을 때 간단하게 사용
+ public static ApiResponse onSuccess(String message) {
+ return onSuccess(HttpStatus.OK, message, null);
}
- public String getMessage() {
- return message;
+ public static ApiResponse onFailure(HttpStatus status, String message) {
+ return onFailure(status, message, null);
}
- public T getData() {
- return data;
- }
+ // Getter
+ public int getStatus() { return status; }
+ public String getMessage() { return message; }
+ public T getData() { return data; }
}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/config/JwtAuthenticationFilter.java b/blog_manage/src/main/java/com/leets/backend/blog/config/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..91e419a
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/config/JwtAuthenticationFilter.java
@@ -0,0 +1,41 @@
+package com.leets.backend.blog.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.web.filter.OncePerRequestFilter;
+import com.leets.backend.blog.service.CustomUserDetailsService;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import java.io.IOException;
+
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final CustomUserDetailsService userDetailsService;
+
+ public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, CustomUserDetailsService userDetailsService) {
+ this.jwtTokenProvider = jwtTokenProvider;
+ this.userDetailsService = userDetailsService;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+ throws ServletException, IOException {
+ String header = req.getHeader("Authorization");
+ String token = null;
+ if (header != null && header.startsWith("Bearer ")) {
+ token = header.substring(7);
+ }
+ if (token != null && jwtTokenProvider.validateToken(token)) {
+ String email = jwtTokenProvider.getSubject(token);
+ var userDetails = userDetailsService.loadUserByUsername(email);
+ var auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
+ auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+ chain.doFilter(req, res);
+ }
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/config/JwtEntryPoint.java b/blog_manage/src/main/java/com/leets/backend/blog/config/JwtEntryPoint.java
new file mode 100644
index 0000000..43b236a
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/config/JwtEntryPoint.java
@@ -0,0 +1,23 @@
+package com.leets.backend.blog.config;
+
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+import jakarta.servlet.http.*;
+import java.io.IOException;
+
+@Component
+public class JwtEntryPoint implements AuthenticationEntryPoint {
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException authException) throws IOException {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
+ response.setContentType("application/json; charset=UTF-8");
+
+ // 인증 실패 메세지
+ String json = ("{\"message\":\"Unauthorized - 인증이 필요합니다.\"}");
+
+ response.getWriter().write(json);
+ }
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/config/JwtTokenProvider.java b/blog_manage/src/main/java/com/leets/backend/blog/config/JwtTokenProvider.java
new file mode 100644
index 0000000..18a3b7f
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/config/JwtTokenProvider.java
@@ -0,0 +1,83 @@
+package com.leets.backend.blog.config;
+
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.security.Key;
+import java.util.Date;
+
+@Component
+public class JwtTokenProvider {
+
+ private final Key key;
+ private final long accessTokenValidityMs;
+ private final long refreshTokenValidityMs;
+
+ public JwtTokenProvider(
+ @Value("${spring.jwt.secret}") String secret,
+ @Value("${spring.jwt.access-token-validity}") long accessValidity,
+ @Value("${spring.jwt.refresh-token-validity}") long refreshValidity) {
+
+ this.key = Keys.hmacShaKeyFor(secret.getBytes());
+ this.accessTokenValidityMs = accessValidity;
+ this.refreshTokenValidityMs = refreshValidity;
+ }
+
+ public String createAccessToken(String subject, String role) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + accessTokenValidityMs);
+
+ return Jwts.builder()
+ .setSubject(subject)
+ .claim("role", role)
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ public String createRefreshToken(String subject) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + refreshTokenValidityMs);
+
+ return Jwts.builder()
+ .setSubject(subject)
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ public String generateToken(String subject) {
+ return createAccessToken(subject, "ROLE_USER");
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ Jwts.parser().setSigningKey(key).build().parseClaimsJws(token);
+ return true;
+ } catch (JwtException | IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ public String getSubject(String token) {
+ return Jwts.parser().setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody()
+ .getSubject();
+ }
+
+ public Date getExpiration(String token) {
+ return Jwts.parser().setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody()
+ .getExpiration();
+ }
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/config/OpenApiConfig.java b/blog_manage/src/main/java/com/leets/backend/blog/config/OpenApiConfig.java
new file mode 100644
index 0000000..9f24c81
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/config/OpenApiConfig.java
@@ -0,0 +1,40 @@
+package com.leets.backend.blog.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 OpenApiConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ // Security Scheme 이름 정의
+ String securitySchemeName = "Bearer Authentication";
+
+ // 모든 API에 이 Security Requirement를 적용
+ SecurityRequirement securityRequirement = new SecurityRequirement()
+ .addList(securitySchemeName);
+
+ // JWT Bearer Token을 위한 Security Scheme 정의
+ SecurityScheme securityScheme = new SecurityScheme()
+ .name(securitySchemeName)
+ .type(SecurityScheme.Type.HTTP) // HTTP 인증 방식
+ .scheme("bearer") // Bearer 토큰 사용
+ .bearerFormat("JWT") // JWT 형식
+ .description("JWT 토큰을 입력하세요. 'Bearer ' 접두사는 자동으로 추가됩니다.");
+
+ return new OpenAPI()
+ .info(new Info()
+ .title("Blog Management API")
+ .description("블로그 관리 시스템 API 문서")
+ .version("1.0.0"))
+ .addSecurityItem(securityRequirement) // 모든 API에 보안 적용
+ .components(new Components()
+ .addSecuritySchemes(securitySchemeName, securityScheme));
+ }
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/config/SecurityConfig.java b/blog_manage/src/main/java/com/leets/backend/blog/config/SecurityConfig.java
index 1ee7d97..5d35604 100644
--- a/blog_manage/src/main/java/com/leets/backend/blog/config/SecurityConfig.java
+++ b/blog_manage/src/main/java/com/leets/backend/blog/config/SecurityConfig.java
@@ -1,49 +1,84 @@
package com.leets.backend.blog.config;
+import com.leets.backend.blog.service.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
+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
+@EnableMethodSecurity
public class SecurityConfig {
+ private final JwtTokenProvider jwtTokenProvider;
+ private final CustomUserDetailsService userDetailsService;
+ private final JwtEntryPoint jwtEntryPoint;
+
+ // 생성자를 통한 의존성 주입
+ public SecurityConfig(
+ JwtTokenProvider jwtTokenProvider,
+ CustomUserDetailsService userDetailsService,
+ JwtEntryPoint jwtEntryPoint
+ ) {
+ this.jwtTokenProvider = jwtTokenProvider;
+ this.userDetailsService = userDetailsService;
+ this.jwtEntryPoint = jwtEntryPoint;
+ }
+ // 비밀번호 인코더 Bean 등록
@Bean
- public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
- http
- // 세션 비활성화
- .sessionManagement(session -> session
- .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
- .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 (API 서버에서는 보통 비활성화)
- .cors(cors -> cors.disable())
+ // AuthenticationManager Bean 등록
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
+ return authenticationConfiguration.getAuthenticationManager();
+ }
+
+ // JWT 인증 필터 Bean 등록
+ @Bean
+ public JwtAuthenticationFilter jwtAuthenticationFilter() {
+ return new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService);
+ }
- // 폼 로그인, http 인증 비활성화
- .formLogin(form -> form.disable())
- .httpBasic(basic -> basic.disable())
+ // 보안 필터 체인 설정
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf.disable())
- // 요청에 대한 인가(Authorization) 설정
- .authorizeHttpRequests(authorize -> authorize
- // Swagger UI 및 API 문서 경로에 대한 접근 허용
+ .authorizeHttpRequests(auth -> auth
.requestMatchers(
+ "/api/auth/**",
"/v3/api-docs/**",
"/swagger-ui/**",
- "/swagger-ui.html",
- "/swagger-resources/**",
- "/webjars/**",
- "/api/**"
- ).permitAll()
- .requestMatchers(
- "/posts/**",
- "/"
+ "/swagger-ui.html"
).permitAll()
- // 그 외 모든 요청은 인가 필요
+ .requestMatchers("/api/auth/kakao/callback").permitAll()
+ .requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
+ )
+
+ .sessionManagement(sess ->
+ sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ )
+
+ .exceptionHandling(ex ->
+ ex.authenticationEntryPoint(jwtEntryPoint)
);
+ http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
+
return http.build();
}
-}
\ No newline at end of file
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/controller/AuthController.java b/blog_manage/src/main/java/com/leets/backend/blog/controller/AuthController.java
new file mode 100644
index 0000000..acbf338
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/controller/AuthController.java
@@ -0,0 +1,47 @@
+package com.leets.backend.blog.controller;
+
+import com.leets.backend.blog.dto.auth.*;
+import com.leets.backend.blog.common.ApiResponse;
+import com.leets.backend.blog.service.AuthService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController {
+
+ private final AuthService authService;
+ public AuthController(AuthService authService) {
+ this.authService = authService;
+ }
+
+ // 회원가입
+ @PostMapping("/signup")
+ public ResponseEntity> signup(@RequestBody SignUpRequest req) {
+ authService.signup(req);
+
+ return ResponseEntity.ok(ApiResponse.onSuccess("User registered"));
+ }
+
+ // 로그인
+ @PostMapping("/login")
+ public ResponseEntity> login(@RequestBody LoginRequest req) {
+ TokenResponse tokens = authService.login(req);
+ return ResponseEntity.ok(ApiResponse.onSuccess(HttpStatus.OK, "Login success", tokens));
+ }
+
+ // 토큰 재발급
+ @PostMapping("/refresh")
+ public ResponseEntity> refresh(@RequestBody RefreshTokenRequest rreq) {
+ TokenResponse tokens = authService.refreshToken(rreq.getRefreshToken());
+ return ResponseEntity.ok(ApiResponse.onSuccess(HttpStatus.OK, "Token refreshed", tokens));
+ }
+
+ // 로그아웃
+ @PostMapping("/logout")
+ public ResponseEntity> logout(@RequestParam String email) {
+ authService.logout(email);
+ return ResponseEntity.ok(ApiResponse.onSuccess("Logged out"));
+ }
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/controller/CommentController.java b/blog_manage/src/main/java/com/leets/backend/blog/controller/CommentController.java
new file mode 100644
index 0000000..162a2bd
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/controller/CommentController.java
@@ -0,0 +1,58 @@
+package com.leets.backend.blog.controller;
+
+import com.leets.backend.blog.common.ApiResponse;
+import com.leets.backend.blog.dto.CommentCreateRequest;
+import com.leets.backend.blog.dto.CommentResponse;
+import com.leets.backend.blog.dto.CommentUpdateRequest;
+import com.leets.backend.blog.service.CommentService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@Tag(name = "Comment API", description = "댓글 관련 API")
+@RestController
+@RequestMapping("/posts/{postId}/comments")
+public class CommentController {
+
+ private final CommentService commentService;
+
+ public CommentController(CommentService commentService) {
+ this.commentService = commentService;
+ }
+
+ @Operation(summary = "댓글 생성", description = "새로운 댓글을 생성합니다.")
+ @PostMapping
+ public ResponseEntity> createComment(
+ @PathVariable Long postId, @Valid @RequestBody CommentCreateRequest request
+ ) {
+ CommentResponse response = commentService.createComment(postId, request);
+ return new ResponseEntity<>(
+ ApiResponse.onSuccess(HttpStatus.CREATED, "댓글 생성 완료", response),
+ HttpStatus.CREATED
+ );
+ }
+
+ @Operation(summary = "댓글 수정", description = "댓글 내용을 수정합니다.")
+ @PatchMapping("/{commentId}")
+ public ResponseEntity> updateComment(
+ @PathVariable Long postId, @PathVariable Long commentId, @Valid @RequestBody CommentUpdateRequest request
+ ) {
+ CommentResponse response = commentService.updateComment(postId, commentId, request);
+ return ResponseEntity.ok(ApiResponse.onSuccess(HttpStatus.OK, "댓글 수정 완료", response));
+ }
+
+ @Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.")
+ @DeleteMapping("/{commentId}")
+ public ResponseEntity> deleteComment(
+ @PathVariable Long postId, @PathVariable Long commentId
+ ) {
+ commentService.deleteComment(postId, commentId);
+ return new ResponseEntity<>(
+ ApiResponse.onSuccess(HttpStatus.NO_CONTENT, "댓글 삭제 완료",null),
+ HttpStatus.NO_CONTENT
+ );
+ }
+}
diff --git a/blog_manage/src/main/java/com/leets/backend/blog/controller/KakaoAuthController.java b/blog_manage/src/main/java/com/leets/backend/blog/controller/KakaoAuthController.java
new file mode 100644
index 0000000..5e0d1d1
--- /dev/null
+++ b/blog_manage/src/main/java/com/leets/backend/blog/controller/KakaoAuthController.java
@@ -0,0 +1,62 @@
+package com.leets.backend.blog.controller;
+
+import com.leets.backend.blog.dto.kakao.KakaoLoginResponse;
+import com.leets.backend.blog.service.KakaoAuthService;
+import com.leets.backend.blog.service.RefreshTokenService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/auth/kakao")
+public class KakaoAuthController {
+
+ private final KakaoAuthService kakaoAuthService;
+ private final RefreshTokenService tokenService;
+
+ public KakaoAuthController(KakaoAuthService kakaoAuthService, RefreshTokenService tokenService) {
+ this.kakaoAuthService = kakaoAuthService;
+ this.tokenService = tokenService;
+ }
+
+ /**
+ * ① 카카오 로그인 URL 제공
+ * 프론트가 이 URL을 받아서 카카오 로그인 창으로 이동함
+ */
+ @GetMapping("/login-url")
+ public ResponseEntity