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> getKakaoLoginUrl() { + String loginUrl = kakaoAuthService.getKakaoLoginUrl(); + + Map response = new HashMap<>(); + response.put("loginUrl", loginUrl); + + return ResponseEntity.ok(response); + } + + /** + * ② 카카오 redirect_uri 에서 받는 callback + * 카카오가 인가코드(code)를 주면 백엔드가 이를 기반으로 로그인 수행 + */ + @GetMapping("/callback") + public ResponseEntity kakaoCallback(@RequestParam("code") String code) { + KakaoLoginResponse response = kakaoAuthService.loginWithCode(code); + return ResponseEntity.ok(response); + } + + /** + * ③ Refresh Token을 이용해 Access Token 재발급 + */ + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody Map request) { + String refreshToken = request.get("refreshToken"); + + String newAccessToken = tokenService.refreshAccessToken(refreshToken); + + Map response = new HashMap<>(); + response.put("accessToken", newAccessToken); + + return ResponseEntity.ok(response); + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/controller/PostController.java b/blog_manage/src/main/java/com/leets/backend/blog/controller/PostController.java index 2768251..d19caea 100644 --- a/blog_manage/src/main/java/com/leets/backend/blog/controller/PostController.java +++ b/blog_manage/src/main/java/com/leets/backend/blog/controller/PostController.java @@ -1,13 +1,20 @@ package com.leets.backend.blog.controller; import com.leets.backend.blog.dto.PostCreateRequest; +import com.leets.backend.blog.dto.PostResponse; import com.leets.backend.blog.dto.PostUpdateRequest; -import com.leets.backend.blog.entity.Post; import com.leets.backend.blog.service.PostService; +import com.leets.backend.blog.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import java.util.List; +@Tag(name = "Post API", description = "게시물 관련 API") @RestController @RequestMapping("/api/posts") public class PostController { @@ -18,33 +25,60 @@ public PostController(PostService postService) { this.postService = postService; } - // 게시글 전체 조회 + // 게시물 목록 조회 + @Operation(summary = "게시물 목록 조회", description = "게시물 목록을 조회합니다.") @GetMapping - public List getAllPosts() { - return postService.findAll(); + public ResponseEntity>> getAllPosts() { + List posts = postService.findAll(); + return ResponseEntity.ok( + ApiResponse.onSuccess(HttpStatus.OK, "게시물 목록 조회 완료", posts) + ); } - // 게시글 상세 조회 - @GetMapping("/{id}") - public Post getPostById(@PathVariable Long id) { - return postService.findById(id); + // 게시물 상세 조회 + @Operation(summary = "게시물 상세 조회", description = "특정 게시물을 조회합니다.") + @GetMapping("/{postId}") + public ResponseEntity> getPostDetail(@PathVariable Long postId) { + PostResponse response = postService.findById(postId); + return ResponseEntity.ok( + ApiResponse.onSuccess(HttpStatus.OK, "게시물 상세 조회 완료", response) + ); } - // 게시글 작성 (PostCreateRequest 사용) + // 게시물 생성 + @Operation(summary = "게시물 생성", description = "새로운 게시물을 생성합니다.") @PostMapping - public Post createPost(@RequestBody PostCreateRequest request) { - return postService.createPost(request); + public ResponseEntity> createPost( + @Valid @RequestBody PostCreateRequest request + ) { + PostResponse response = postService.createPost(request); + return new ResponseEntity<>( + ApiResponse.onSuccess(HttpStatus.CREATED, "게시물 생성 완료", response), + HttpStatus.CREATED + ); } - // 게시글 수정 (PostUpdateRequest 사용) + // 게시물 수정 + @Operation(summary = "게시물 수정", description = "게시물 내용을 수정합니다.") @PutMapping("/{id}") - public Post updatePost(@PathVariable Long id, @RequestBody PostUpdateRequest request) { - return postService.updatePost(id, request); + public ResponseEntity> updatePost( + @PathVariable Long id, + @RequestBody PostUpdateRequest request + ) { + PostResponse response = postService.updatePost(id, request); + return ResponseEntity.ok( + ApiResponse.onSuccess(HttpStatus.OK, "게시물 수정 완료", response) + ); } - // 게시글 삭제 + // 게시물 삭제 + @Operation(summary = "게시물 삭제", description = "게시물을 삭제합니다.") @DeleteMapping("/{id}") - public void deletePost(@PathVariable Long id) { + public ResponseEntity> deletePost(@PathVariable Long id) { postService.deletePost(id); + return new ResponseEntity<>( + ApiResponse.onSuccess(HttpStatus.NO_CONTENT, "게시물 삭제 완료", null), + HttpStatus.NO_CONTENT + ); } } diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentCreateRequest.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentCreateRequest.java new file mode 100644 index 0000000..223a15b --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentCreateRequest.java @@ -0,0 +1,13 @@ +package com.leets.backend.blog.dto; + +import jakarta.validation.constraints.NotBlank; + +public class CommentCreateRequest { + @NotBlank(message = "댓글 내용은 비워둘 수 없습니다.") + private String content; + + public CommentCreateRequest() { } + + // getters + public String getContent() { return content; } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentResponse.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentResponse.java new file mode 100644 index 0000000..3370a8a --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentResponse.java @@ -0,0 +1,39 @@ +package com.leets.backend.blog.dto; + +import com.leets.backend.blog.entity.Comment; + +import java.time.LocalDateTime; + +public class CommentResponse { + private Long commentId; + private String content; + private String nickname; + private LocalDateTime createdAt; + + public CommentResponse() {} + public static CommentResponse from(Comment comment) { + CommentResponse response = new CommentResponse(); + + response.commentId = comment.getCommentId(); + response.content = comment.getContent(); + response.nickname = comment.getUser().getNickname(); + response.createdAt = comment.getCreatedAt(); + + return response; + } + public Long getCommentId() { + return commentId; + } + + public String getContent() { + return content; + } + + public String getNickname() { + return nickname; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentUpdateRequest.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..fabb303 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/CommentUpdateRequest.java @@ -0,0 +1,12 @@ +package com.leets.backend.blog.dto; +import jakarta.validation.constraints.NotBlank; + +public class CommentUpdateRequest { + @NotBlank(message = "댓글 내용은 비워둘 수 없습니다.") + private String content; + + public CommentUpdateRequest() { } + + //getters + public String getContent() { return content; } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/LoginRequest.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/LoginRequest.java new file mode 100644 index 0000000..56fe790 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/LoginRequest.java @@ -0,0 +1,28 @@ +package com.leets.backend.blog.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class LoginRequest { + + @Email + @NotBlank + private String email; + + @NotBlank + private String password; + + public LoginRequest() {} + + public LoginRequest(String email, String password) { + this.email = email; + this.password = password; + } + + // getters / setters + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/RefreshTokenRequest.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/RefreshTokenRequest.java new file mode 100644 index 0000000..369c440 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/RefreshTokenRequest.java @@ -0,0 +1,13 @@ +package com.leets.backend.blog.dto.auth; + +public class RefreshTokenRequest { + private String refreshToken; + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/SignUpRequest.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/SignUpRequest.java new file mode 100644 index 0000000..9f8b2c5 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/SignUpRequest.java @@ -0,0 +1,56 @@ +package com.leets.backend.blog.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class SignUpRequest { + + @Email + @NotBlank + private String email; + + @NotBlank + @Size(min = 6, message = "password must be at least 6 characters") + private String password; + + @NotBlank + private String name; + + @NotBlank + private String nickname; + + public SignUpRequest() {} + + public SignUpRequest(String email, String password, String name, String nickname) { + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + } + + // getters / setters + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + + public static class RefreshTokenRequest { + @NotBlank + private String refreshToken; + + public RefreshTokenRequest() {} + + public RefreshTokenRequest(String refreshToken) { this.refreshToken = refreshToken; } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/TokenResponse.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/TokenResponse.java new file mode 100644 index 0000000..48bb26d --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/auth/TokenResponse.java @@ -0,0 +1,25 @@ +package com.leets.backend.blog.dto.auth; + +public class TokenResponse { + private String accessToken; + private String refreshToken; + private long accessTokenExpiresIn; // ms 남은 시간 or 만료 epoch 등 필요에 따라 수정 + + public TokenResponse() {} + + public TokenResponse(String accessToken, String refreshToken, long accessTokenExpiresIn) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.accessTokenExpiresIn = accessTokenExpiresIn; + } + + // getters / setters + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public long getAccessTokenExpiresIn() { return accessTokenExpiresIn; } + public void setAccessTokenExpiresIn(long accessTokenExpiresIn) { this.accessTokenExpiresIn = accessTokenExpiresIn; } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/kakao/KakaoLoginRequest.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/kakao/KakaoLoginRequest.java new file mode 100644 index 0000000..10a7f21 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/kakao/KakaoLoginRequest.java @@ -0,0 +1,21 @@ +package com.leets.backend.blog.dto.kakao; + +public class KakaoLoginRequest { + + private String accessToken; + + public KakaoLoginRequest() { + } + + public KakaoLoginRequest(String accessToken) { + this.accessToken = accessToken; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/dto/kakao/KakaoLoginResponse.java b/blog_manage/src/main/java/com/leets/backend/blog/dto/kakao/KakaoLoginResponse.java new file mode 100644 index 0000000..5c8fda3 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/dto/kakao/KakaoLoginResponse.java @@ -0,0 +1,51 @@ +package com.leets.backend.blog.dto.kakao; + +public class KakaoLoginResponse { + + private String accessToken; + private String refreshToken; + private String email; + private String nickname; + + public KakaoLoginResponse() { + } + + public KakaoLoginResponse(String accessToken, String refreshToken, String email, String nickname) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.email = email; + this.nickname = nickname; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/entity/Comment.java b/blog_manage/src/main/java/com/leets/backend/blog/entity/Comment.java index b542ccc..20b7a17 100644 --- a/blog_manage/src/main/java/com/leets/backend/blog/entity/Comment.java +++ b/blog_manage/src/main/java/com/leets/backend/blog/entity/Comment.java @@ -26,6 +26,21 @@ public class Comment { @JoinColumn(name = "post_id", nullable = false) private Post post; + protected Comment() {} + + public static Comment createComment(String content, User user, Post post) { + Comment comment = new Comment(); + comment.content = content; + comment.user = user; + comment.post = post; + comment.createdAt = LocalDateTime.now(); + return comment; + } + + public void updateComment(String newContent) { + this.content = newContent; + } + // Getters public Long getCommentId() { return commentId; diff --git a/blog_manage/src/main/java/com/leets/backend/blog/entity/RefreshToken.java b/blog_manage/src/main/java/com/leets/backend/blog/entity/RefreshToken.java new file mode 100644 index 0000000..1760156 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/entity/RefreshToken.java @@ -0,0 +1,40 @@ +package com.leets.backend.blog.entity; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "refresh_tokens") +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String token; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "userId") + private User user; + + @Column(nullable = false) + private Instant expiryDate; + + public RefreshToken() {} + + public RefreshToken(String token, User user, Instant expiryDate) { + this.token = token; + this.user = user; + this.expiryDate = expiryDate; + } + + public Long getId() { return id; } + public String getToken() { return token; } + public User getUser() { return user; } + public Instant getExpiryDate() { return expiryDate; } + + public void setToken(String token) { this.token = token; } + public void setUser(User user) { this.user = user; } + public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/entity/User.java b/blog_manage/src/main/java/com/leets/backend/blog/entity/User.java index c7d882c..1ff7e85 100644 --- a/blog_manage/src/main/java/com/leets/backend/blog/entity/User.java +++ b/blog_manage/src/main/java/com/leets/backend/blog/entity/User.java @@ -13,7 +13,7 @@ public class User { private Long userId; @Column(nullable = false, unique = true) - private String email; + private String email; // 로그인 ID (email) @Column(nullable = false) private String name; @@ -27,8 +27,13 @@ public class User { @Column(nullable = true) private String introduction; + // 소셜 로그인 식별자 + @Column(nullable = true, unique = true) + private String socialId; + + // 로그인 제공자 구분 @Column(nullable = true) - private String kakaoId; + private String provider; @Column(nullable = true) private LocalDateTime birth; @@ -39,8 +44,20 @@ public class User { @Column(nullable = false) private LocalDateTime updatedAt; + // 권한(ROLE_USER 등) + @Column(nullable = false) + private String role = "ROLE_USER"; + public User() {} + // 회원가입 등에서 쓸 생성자 + public User(String email, String password, String name, String nickname, String role) { + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + this.role = role != null ? role : "ROLE_USER"; + } @PrePersist protected void onCreate() { @@ -60,25 +77,25 @@ protected void onUpdate() { public String getNickname() { return nickname; } public String getPassword() { return password; } public String getIntroduction() { return introduction; } - public String getKakaoId() { return kakaoId; } + public String getSocialId() { return socialId; } + public String getProvider() { return provider; } public LocalDateTime getBirth() { return birth; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } - - // Setter / 업데이트 관련 메서드 - public void setNickname(String nickname) { - this.nickname = nickname; - } - - public void setPassword(String password) { - this.password = password; - } - - public void setIntroduction(String introduction) { - this.introduction = introduction; - } - - + public String getRole() { return role; } + + // Setter + public void setEmail(String email) { this.email = email; } + public void setName(String name) { this.name = name; } + public void setNickname(String nickname) { this.nickname = nickname; } + public void setPassword(String password) { this.password = password; } + public void setIntroduction(String introduction) { this.introduction = introduction; } + public void setSocialId(String socialId) { this.socialId = socialId; } + public void setProvider(String provider) { this.provider = provider; } + public void setBirth(LocalDateTime birth) { this.birth = birth; } + public void setRole(String role) { this.role = role; } + + // 업데이트 편의 메서드 public void update(String nickname, String password, String introduction) { if (nickname != null && !nickname.isBlank()) { this.nickname = nickname; @@ -92,7 +109,6 @@ public void update(String nickname, String password, String introduction) { this.updatedAt = LocalDateTime.now(); } - public static User createDummy() { User user = new User(); user.email = "dummy@naver.com"; diff --git a/blog_manage/src/main/java/com/leets/backend/blog/exception/CommentAccessDeniedException.java b/blog_manage/src/main/java/com/leets/backend/blog/exception/CommentAccessDeniedException.java new file mode 100644 index 0000000..96e2b9f --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/exception/CommentAccessDeniedException.java @@ -0,0 +1,13 @@ +package com.leets.backend.blog.exception; + +public class CommentAccessDeniedException extends RuntimeException { + private static final String DEFAULT_MESSAGE = "이 댓글에 대한 접근 권한이 없습니다. 작성자만 수정 또는 삭제할 수 있습니다."; + + public CommentAccessDeniedException() { + super(DEFAULT_MESSAGE); + } + + public CommentAccessDeniedException(String message) { + super(message); + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/exception/CommentNotFoundException.java b/blog_manage/src/main/java/com/leets/backend/blog/exception/CommentNotFoundException.java new file mode 100644 index 0000000..18e6a95 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/exception/CommentNotFoundException.java @@ -0,0 +1,13 @@ +package com.leets.backend.blog.exception; + +public class CommentNotFoundException extends RuntimeException { + private static final String DEFAULT_MESSAGE = "댓글을 찾을 수 없습니다."; + + public CommentNotFoundException() { + super(DEFAULT_MESSAGE); + } + + public CommentNotFoundException(Long commentId) { + super(DEFAULT_MESSAGE + " (ID: " + commentId + ")"); + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/exception/GlobalExceptionHandler.java b/blog_manage/src/main/java/com/leets/backend/blog/exception/GlobalExceptionHandler.java index b88b197..e7a9862 100644 --- a/blog_manage/src/main/java/com/leets/backend/blog/exception/GlobalExceptionHandler.java +++ b/blog_manage/src/main/java/com/leets/backend/blog/exception/GlobalExceptionHandler.java @@ -45,6 +45,7 @@ public ResponseEntity> handlePostNotFoundException(PostNotFoun // 500 Internal Server Error @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneralException(Exception exception) { + exception.printStackTrace(); return new ResponseEntity<>( ApiResponse.onFailure(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류 발생", null), HttpStatus.INTERNAL_SERVER_ERROR diff --git a/blog_manage/src/main/java/com/leets/backend/blog/repository/CommentRepository.java b/blog_manage/src/main/java/com/leets/backend/blog/repository/CommentRepository.java new file mode 100644 index 0000000..c41d421 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/repository/CommentRepository.java @@ -0,0 +1,8 @@ +package com.leets.backend.blog.repository; + +import com.leets.backend.blog.entity.Comment; +import com.leets.backend.blog.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/repository/RefreshTokenRepository.java b/blog_manage/src/main/java/com/leets/backend/blog/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..7948495 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.leets.backend.blog.repository; + +import com.leets.backend.blog.entity.RefreshToken; +import com.leets.backend.blog.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + Optional findByUser(User user); + void deleteByUser(User user); +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/repository/UserRepository.java b/blog_manage/src/main/java/com/leets/backend/blog/repository/UserRepository.java index 7ed5ac7..db1d0ad 100644 --- a/blog_manage/src/main/java/com/leets/backend/blog/repository/UserRepository.java +++ b/blog_manage/src/main/java/com/leets/backend/blog/repository/UserRepository.java @@ -4,7 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { - User findByEmail(String email); + Optional findByEmail(String email); + boolean existsByEmail(String email); + Optional findBySocialId(String socialId); } \ No newline at end of file diff --git a/blog_manage/src/main/java/com/leets/backend/blog/service/AuthService.java b/blog_manage/src/main/java/com/leets/backend/blog/service/AuthService.java new file mode 100644 index 0000000..c39f6ab --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/service/AuthService.java @@ -0,0 +1,107 @@ +package com.leets.backend.blog.service; + +import com.leets.backend.blog.config.JwtTokenProvider; +import com.leets.backend.blog.dto.auth.*; +import com.leets.backend.blog.entity.RefreshToken; +import com.leets.backend.blog.entity.User; +import com.leets.backend.blog.repository.RefreshTokenRepository; +import com.leets.backend.blog.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Date; + +@Service +public class AuthService { + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + public AuthService(UserRepository userRepository, + RefreshTokenRepository refreshTokenRepository, + PasswordEncoder passwordEncoder, + JwtTokenProvider jwtTokenProvider) { + this.userRepository = userRepository; + this.refreshTokenRepository = refreshTokenRepository; + this.passwordEncoder = passwordEncoder; + this.jwtTokenProvider = jwtTokenProvider; + } + + // 회원가입 + public void signup(SignUpRequest req) { + if (userRepository.existsByEmail(req.getEmail())) { + throw new RuntimeException("Email already in use"); + } + + String encoded = passwordEncoder.encode(req.getPassword()); + + User user = new User( + req.getEmail(), + encoded, + req.getName(), + req.getNickname(), + "ROLE_USER" + ); + + userRepository.save(user); + } + + public TokenResponse login(LoginRequest req) { + User user = userRepository.findByEmail(req.getEmail()) + .orElseThrow(() -> new RuntimeException("Invalid credentials")); + + if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) { + throw new RuntimeException("Invalid credentials"); + } + + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole()); + String refreshTokenString = jwtTokenProvider.createRefreshToken(user.getEmail()); + + // 기존 엔티티가 있으면 가져오고, 없으면 새 엔티티 생성 + RefreshToken tokenEntity = refreshTokenRepository.findByUser(user) + .orElseGet(RefreshToken::new); + + tokenEntity.setToken(refreshTokenString); + tokenEntity.setUser(user); + tokenEntity.setExpiryDate( + Instant.ofEpochMilli(jwtTokenProvider.getExpiration(refreshTokenString).getTime()) + ); + + refreshTokenRepository.save(tokenEntity); + + long expiresIn = jwtTokenProvider.getExpiration(accessToken).getTime() - new Date().getTime(); + return new TokenResponse(accessToken, refreshTokenString, expiresIn); + } + + public TokenResponse refreshToken(String refreshToken) { + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new RuntimeException("Invalid refresh token"); + } + + String email = jwtTokenProvider.getSubject(refreshToken); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + RefreshToken stored = refreshTokenRepository.findByUser(user) + .orElseThrow(() -> new RuntimeException("Refresh token not found")); + + // NPE 방지: equals 호출은 외부(인자) 문자열에서 수행 -> 안전하게 비교 + if (!refreshToken.equals(stored.getToken()) || stored.getExpiryDate().isBefore(Instant.now())) { + throw new RuntimeException("Refresh token invalid or expired"); + } + + String newAccess = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole()); + long expiresIn = jwtTokenProvider.getExpiration(newAccess).getTime() - new Date().getTime(); + + return new TokenResponse(newAccess, refreshToken, expiresIn); + } + + // 로그아웃: 해당 유저의 리프레시 토큰 삭제 + public void logout(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + refreshTokenRepository.deleteByUser(user); + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/service/CommentService.java b/blog_manage/src/main/java/com/leets/backend/blog/service/CommentService.java new file mode 100644 index 0000000..cf50d63 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/service/CommentService.java @@ -0,0 +1,117 @@ +package com.leets.backend.blog.service; + +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.entity.Comment; +import com.leets.backend.blog.entity.Post; +import com.leets.backend.blog.entity.User; +import com.leets.backend.blog.exception.CommentAccessDeniedException; +import com.leets.backend.blog.exception.CommentNotFoundException; +import com.leets.backend.blog.exception.PostNotFoundException; +import com.leets.backend.blog.repository.CommentRepository; +import com.leets.backend.blog.repository.PostRepository; +import com.leets.backend.blog.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class CommentService { + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + private static final String DUMMY_USER_EMAIL = "dummy@naver.com"; + + public CommentService(CommentRepository commentRepository, + PostRepository postRepository, + UserRepository userRepository) { + this.commentRepository = commentRepository; + this.postRepository = postRepository; + this.userRepository = userRepository; + } + + // 댓글 생성 + public CommentResponse createComment(Long postId, CommentCreateRequest request) { + User user = findOrCreateDummyUser(); + + // 게시글 존재 확인 + Post post = findPostById(postId); + + Comment comment = Comment.createComment(request.getContent(), user, post); + commentRepository.save(comment); + + return CommentResponse.from(comment); + } + + // 댓글 수정 + public CommentResponse updateComment(Long postId, Long commentId, CommentUpdateRequest request) { + User user = findOrCreateDummyUser(); + + // 댓글 조회 및 검증 (게시글, 댓글 존재, 작성자 확인) + Comment comment = validateCommentAccess(postId, commentId, user); + + comment.updateComment(request.getContent()); + return CommentResponse.from(comment); + } + + // 댓글 삭제 + public void deleteComment(Long postId, Long commentId) { + User user = findOrCreateDummyUser(); + + // 댓글 조회 및 검증 (게시글, 댓글 존재, 작성자 확인) + Comment comment = validateCommentAccess(postId, commentId, user); + + commentRepository.delete(comment); + } + + + // 더미 유저가 없으면 생성하고, 있으면 반환 + private User findOrCreateDummyUser() { + return userRepository.findByEmail(DUMMY_USER_EMAIL) + .orElseGet(this::createDummyUser); + } + + // 더미 유저를 DB에 생성 (이미 존재하면 중복 예외 방지) + private User createDummyUser() { + User dummy = User.createDummy(); + + // 혹시 병렬 요청 등으로 이미 생성되어 있을 경우 방어 + return userRepository.findByEmail(DUMMY_USER_EMAIL) + .orElseGet(() -> userRepository.save(dummy)); + } + + // 게시글 ID로 게시글을 찾아 반환하고, 없으면 예외 발생 + private Post findPostById(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + } + + private Comment validateCommentAccess(Long postId, Long commentId, User user) { + // 게시글 존재 확인 + findPostById(postId); + + // 댓글 존재 확인 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentNotFoundException(commentId)); + + // 댓글의 게시글 일치 여부 확인 + if (!comment.getPost().getPostId().equals(postId)) { + throw new CommentNotFoundException(commentId); + } + + // 작성자 검증 + checkCommentAuthor(comment, user); + + return comment; + } + + // 댓글 작성자 검증 + private void checkCommentAuthor(Comment comment, User user) { + if (!comment.getUser().getUserId().equals(user.getUserId())) { + throw new CommentAccessDeniedException(); + } + } +} \ No newline at end of file diff --git a/blog_manage/src/main/java/com/leets/backend/blog/service/CustomUserDetailsService.java b/blog_manage/src/main/java/com/leets/backend/blog/service/CustomUserDetailsService.java new file mode 100644 index 0000000..1ac5804 --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/service/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.leets.backend.blog.service; + +import com.leets.backend.blog.entity.User; +import com.leets.backend.blog.repository.UserRepository; +import org.springframework.security.core.userdetails.*; +import org.springframework.stereotype.Service; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + + +import java.util.Collections; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + public CustomUserDetailsService(UserRepository repo) { this.userRepository = repo; } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + return new org.springframework.security.core.userdetails.User( + user.getEmail(), + user.getPassword(), + Collections.singletonList(new SimpleGrantedAuthority(user.getRole())) + ); + } +} diff --git a/blog_manage/src/main/java/com/leets/backend/blog/service/KakaoAuthService.java b/blog_manage/src/main/java/com/leets/backend/blog/service/KakaoAuthService.java new file mode 100644 index 0000000..630dd9c --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/service/KakaoAuthService.java @@ -0,0 +1,166 @@ +package com.leets.backend.blog.service; + +import com.leets.backend.blog.dto.kakao.KakaoLoginResponse; +import com.leets.backend.blog.entity.RefreshToken; +import com.leets.backend.blog.entity.User; +import com.leets.backend.blog.repository.RefreshTokenRepository; +import com.leets.backend.blog.repository.UserRepository; +import com.leets.backend.blog.config.JwtTokenProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Service +public class KakaoAuthService { + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.client-secret}") + private String clientSecret; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + @Value("${kakao.token-uri}") + private String tokenUri; + + @Value("${kakao.user-info-uri}") + private String userInfoUri; + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RestTemplate restTemplate = new RestTemplate(); + + private static final long REFRESH_TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60; + + public KakaoAuthService(UserRepository userRepository, + RefreshTokenRepository refreshTokenRepository, + JwtTokenProvider jwtTokenProvider) { + this.userRepository = userRepository; + this.refreshTokenRepository = refreshTokenRepository; + this.jwtTokenProvider = jwtTokenProvider; + } + + /** + * ① 프론트가 이동해야 할 카카오 로그인 URL 생성 + */ + public String getKakaoLoginUrl() { + return "https://kauth.kakao.com/oauth/authorize" + + "?client_id=" + clientId + + "&redirect_uri=" + redirectUri + + "&response_type=code"; + } + + /** + * ② callback의 code로 Access Token 요청 → 사용자 정보 조회 → 회원가입/로그인 처리 + */ + public KakaoLoginResponse loginWithCode(String code) { + + // (1) code로 Access Token 요청 + Map tokenResponse = requestAccessToken(code); + String kakaoAccessToken = tokenResponse.get("access_token"); + + // (2) 사용자 정보 요청 + Map userInfo = requestUserInfo(kakaoAccessToken); + + String kakaoId = String.valueOf(userInfo.get("id")); + Map kakaoAccount = (Map) userInfo.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + String email = kakaoAccount.get("email") != null + ? kakaoAccount.get("email").toString() + : kakaoId + "@kakao.com"; + + String nickname = profile != null && profile.get("nickname") != null + ? profile.get("nickname").toString() + : "KakaoUser"; + + // (3) DB 조회 or 회원가입 + User user = userRepository.findBySocialId(kakaoId).orElseGet(() -> { + User newUser = new User(); + newUser.setSocialId(kakaoId); + newUser.setEmail(email); + newUser.setNickname(nickname); + newUser.setProvider("kakao"); + newUser.setRole("ROLE_USER"); + return userRepository.save(newUser); + }); + + // (4) 자체 JWT 발급 + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRole()); + String refreshTokenValue = jwtTokenProvider.createRefreshToken(user.getEmail()); + + refreshTokenRepository.deleteByUser(user); + + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setUser(user); + refreshToken.setToken(refreshTokenValue); + refreshToken.setExpiryDate(Instant.now().plusSeconds(REFRESH_TOKEN_EXPIRY_SECONDS)); + + refreshTokenRepository.save(refreshToken); + + return new KakaoLoginResponse(accessToken, refreshTokenValue, email, nickname); + } + + /** + * (A) code → Access Token 요청 (✅ 수정된 부분) + */ + private Map requestAccessToken(String code) { + + // 1. 헤더 설정: Content-Type을 x-www-form-urlencoded로 명시 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + // 2. 요청 본문(Body) 설정: HashMap 대신 MultiValueMap 사용 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + + // 💡 client-secret 추가 (필수 항목은 아니지만 보안 강화를 위해 사용) + params.add("client_secret", clientSecret); + + params.add("redirect_uri", redirectUri); + params.add("code", code); + + // 3. HttpEntity 구성: 헤더와 본문을 담아 요청 객체 생성 + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.exchange( + tokenUri, + HttpMethod.POST, + request, + Map.class + ); + + return response.getBody(); + } + + /** + * (B) Access Token → 사용자 정보 요청 + */ + private Map requestUserInfo(String accessToken) { + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + userInfoUri, + HttpMethod.GET, + request, + Map.class + ); + + return response.getBody(); + } +} \ No newline at end of file diff --git a/blog_manage/src/main/java/com/leets/backend/blog/service/PostService.java b/blog_manage/src/main/java/com/leets/backend/blog/service/PostService.java index ae3bd86..6504c8b 100644 --- a/blog_manage/src/main/java/com/leets/backend/blog/service/PostService.java +++ b/blog_manage/src/main/java/com/leets/backend/blog/service/PostService.java @@ -1,6 +1,7 @@ package com.leets.backend.blog.service; import com.leets.backend.blog.dto.PostCreateRequest; +import com.leets.backend.blog.dto.PostResponse; import com.leets.backend.blog.dto.PostUpdateRequest; import com.leets.backend.blog.entity.Post; import com.leets.backend.blog.entity.User; @@ -11,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.stream.Collectors; @Service @Transactional(readOnly = true) @@ -26,40 +28,52 @@ public PostService(PostRepository postRepository, UserRepository userRepository) this.userRepository = userRepository; } - public List findAll() { - return postRepository.findAll(); + // 게시물 목록 조회 + public List findAll() { + return postRepository.findAll() + .stream() + .map(PostResponse::from) + .collect(Collectors.toList()); } - public Post findById(Long id) { - return postRepository.findById(id) + // 게시물 상세 조회 + public PostResponse findById(Long id) { + Post post = postRepository.findById(id) .orElseThrow(() -> new PostNotFoundException(id)); + return PostResponse.from(post); } + // 게시물 생성 @Transactional - public Post createPost(PostCreateRequest request) { + public PostResponse createPost(PostCreateRequest request) { User user = findOrCreateDummyUser(); Post post = new Post(user, request.getTitle(), request.getContent()); - return postRepository.save(post); + Post saved = postRepository.save(post); + return PostResponse.from(saved); } + // 게시물 수정 @Transactional - public Post updatePost(Long id, PostUpdateRequest request) { - Post post = findById(id); - User dummyUser = findOrCreateDummyUser(); + public PostResponse updatePost(Long id, PostUpdateRequest request) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new PostNotFoundException(id)); + User dummyUser = findOrCreateDummyUser(); if (!post.getUser().getUserId().equals(dummyUser.getUserId())) { throw new IllegalArgumentException("본인 게시글만 수정할 수 있습니다."); } post.updatePost(request.getTitle(), request.getContent()); - return postRepository.save(post); + return PostResponse.from(postRepository.save(post)); } + // 게시물 삭제 @Transactional public void deletePost(Long id) { - Post post = findById(id); - User dummyUser = findOrCreateDummyUser(); + Post post = postRepository.findById(id) + .orElseThrow(() -> new PostNotFoundException(id)); + User dummyUser = findOrCreateDummyUser(); if (!post.getUser().getUserId().equals(dummyUser.getUserId())) { throw new IllegalArgumentException("본인 게시글만 삭제할 수 있습니다."); } @@ -68,11 +82,7 @@ public void deletePost(Long id) { } private User findOrCreateDummyUser() { - User user = userRepository.findByEmail(DUMMY_EMAIL); - if (user == null) { - user = User.createDummy(); - return userRepository.save(user); - } - return user; + return userRepository.findByEmail(DUMMY_EMAIL) + .orElseGet(() -> userRepository.save(User.createDummy())); } } diff --git a/blog_manage/src/main/java/com/leets/backend/blog/service/RefreshTokenService.java b/blog_manage/src/main/java/com/leets/backend/blog/service/RefreshTokenService.java new file mode 100644 index 0000000..a62b24f --- /dev/null +++ b/blog_manage/src/main/java/com/leets/backend/blog/service/RefreshTokenService.java @@ -0,0 +1,36 @@ +// src/main/java/com/leets/backend/blog/service/RefreshTokenService.java +package com.leets.backend.blog.service; + +import com.leets.backend.blog.config.JwtTokenProvider; +import com.leets.backend.blog.entity.RefreshToken; +import com.leets.backend.blog.repository.RefreshTokenRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + + public RefreshTokenService(RefreshTokenRepository refreshTokenRepository, + JwtTokenProvider jwtTokenProvider) { + this.refreshTokenRepository = refreshTokenRepository; + this.jwtTokenProvider = jwtTokenProvider; + } + + public String refreshAccessToken(String refreshTokenValue) { + Optional refreshTokenOpt = refreshTokenRepository.findByToken(refreshTokenValue); + + if (refreshTokenOpt.isEmpty()) { + throw new RuntimeException("유효하지 않은 Refresh Token입니다."); + } + + RefreshToken refreshToken = refreshTokenOpt.get(); + String userEmail = refreshToken.getUser().getEmail(); + + // 새로운 Access Token 생성 + return jwtTokenProvider.generateToken(userEmail); + } +} diff --git a/blog_manage/src/main/resources/application.yml b/blog_manage/src/main/resources/application.yml index 34333b0..bf49e25 100644 --- a/blog_manage/src/main/resources/application.yml +++ b/blog_manage/src/main/resources/application.yml @@ -3,6 +3,7 @@ spring: url: jdbc:mysql://localhost:3306/blogdb?serverTimezone=Asia/Seoul username: root password: 1234 + jpa: hibernate: ddl-auto: update # 개발 단계에서는 update / 운영은 validate 권장 @@ -10,3 +11,33 @@ spring: hibernate: format_sql: true show-sql: true + + mail: + host: smtp.gmail.com + port: 587 + username: your_email@gmail.com + password: your_app_password + properties: + mail.smtp.auth: true + mail.smtp.starttls.enable: true + + jwt: + secret: "ChangeThisToASuperLongRandomSecretKeyForJWT1234567890!@#ABCD" + access-token-validity: 900000 # 15분 (단위: 밀리초) + refresh-token-validity: 1209600000 # 14일 (단위: 밀리초) + +logging: + level: + root: INFO + org.springframework: DEBUG + org.hibernate.SQL: DEBUG + com.leets.backend.blog: DEBUG + org.springframework.security: DEBUG + + +kakao: + client-id: 832cf040e50187a709811294587535ee + client-secret: "STyTYjHaJyNFryjqlbjSK6fdKmoLrEsj" + redirect-uri: http://localhost:8080/api/auth/kakao/callback + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me