diff --git a/.gitignore b/.gitignore index e2d2bbf..653a0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,6 @@ out/ .DS_Store ### application ### -/src/main/resources/application.yml /src/main/resources/application-dev.yml /src/main/resources/application-prod.yml diff --git a/README.md b/README.md index 48165df..d8cc932 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Tech-Post_BE \ No newline at end of file +# Tech-Post_BE. \ No newline at end of file diff --git a/build.gradle b/build.gradle index ef09125..710e577 100644 --- a/build.gradle +++ b/build.gradle @@ -28,10 +28,14 @@ dependencies { // Spring 기본 implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - //implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + //WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // DB & Redis + runtimeOnly 'com.h2database:h2' implementation 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-redis' @@ -42,9 +46,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // JWT - // implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - // runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - // runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + implementation 'io.jsonwebtoken:jjwt-impl:0.13.0' + implementation 'io.jsonwebtoken:jjwt-jackson:0.13.0' // JSON 처리 라이브러리 // Lombok compileOnly 'org.projectlombok:lombok' @@ -55,6 +59,11 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'com.h2database:h2' + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { diff --git a/src/main/java/com/ureka/techpost/TechpostApplication.java b/src/main/java/com/ureka/techpost/TechpostApplication.java index edcfb3d..ab2a957 100644 --- a/src/main/java/com/ureka/techpost/TechpostApplication.java +++ b/src/main/java/com/ureka/techpost/TechpostApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class TechpostApplication { diff --git a/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..38a6c74 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/controller/AuthController.java @@ -0,0 +1,67 @@ +package com.ureka.techpost.domain.auth.controller; + +import com.ureka.techpost.domain.auth.dto.LoginDto; +import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.service.AuthService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * @file AuthController.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 인증과 관련된 HTTP 요청을 받아 처리하는 REST 컨트롤러 클래스입니다. + */ +@Tag(name = "Authentication", description = "인증 관련 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + @PostMapping("/signup") + public ApiResponse signup( + @Parameter(description = "회원가입 요청 데이터 (아이디, 비밀번호, 이름)", required = true) + @Valid @RequestBody SignupDto signupDto) { + authService.signup(signupDto); + return ApiResponse.onSuccess("회원가입 성공"); + } + + @Operation(summary = "토큰 재발급", description = "Refresh Token을 사용하여 만료된 Access Token을 재발급합니다. Refresh Token은 쿠키에서, 만료된 Access Token은 헤더에서 가져옵니다.") + @PostMapping("/reissue") + public ApiResponse reissue( + @Parameter(hidden = true) HttpServletRequest request, + @Parameter(hidden = true) HttpServletResponse response) { + return ApiResponse.onSuccess(authService.reissue(request, response)); + } + + @Operation(summary = "로그인", description = "사용자 이름과 비밀번호로 로그인하여 Access Token 및 Refresh Token을 발급받습니다.") + @PostMapping("/login") + public ApiResponse login( + @Parameter(description = "로그인 요청 데이터 (아이디, 비밀번호)", required = true) + @Valid @RequestBody LoginDto loginDto, + @Parameter(hidden = true) HttpServletResponse response) { + authService.login(loginDto, response); + return ApiResponse.onSuccess("로그인 성공"); + } + + @Operation(summary = "로그아웃", description = "Refresh Token을 삭제하고 로그아웃 처리합니다.") + @PostMapping("/logout") + public ResponseEntity logout( + @Parameter(hidden = true) HttpServletRequest request, + @Parameter(hidden = true) HttpServletResponse response) { + authService.logout(request, response); + return ResponseEntity.ok("로그아웃 성공"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java b/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java new file mode 100644 index 0000000..14f1a2c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/CustomUserDetails.java @@ -0,0 +1,73 @@ +package com.ureka.techpost.domain.auth.dto; + +import com.ureka.techpost.domain.user.entity.User; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +/** + * @file CustomUserDetails.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 Spring Security의 UserDetails와 OAuth2User 인터페이스를 구현한 커스텀 클래스입니다. + */ +@Getter +public class CustomUserDetails implements UserDetails, OAuth2User { + + private final User user; + private Map attributes; + + // 일반 로그인 생성자 + public CustomUserDetails(User user) { + this.user = user; + } + + // OAuth2 로그인 생성자 + public CustomUserDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + + // === UserDetails 구현 === + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRoleName(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + // === OAuth2User 구현 === + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.getProviderId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java new file mode 100644 index 0000000..4993400 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/ErrorResponseDto.java @@ -0,0 +1,35 @@ +package com.ureka.techpost.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * @file ErrorResponseDto.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 예외 발생 시 클라이언트에게 반환되는 표준 오류 응답 DTO 클래스입니다. + */ +@Getter +@Builder +public class ErrorResponseDto { + + private final String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + private final int status; + private final String error; + private final String message; + + public static ResponseEntity toResponseEntity(int status, String error, String message) { + return ResponseEntity + .status(status) + .body(ErrorResponseDto.builder() + .status(status) + .error(error) + .message(message) + .build()); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java new file mode 100644 index 0000000..821d9dd --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/LoginDto.java @@ -0,0 +1,20 @@ +package com.ureka.techpost.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * @file LoginDto.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 로그인 요청에 사용되는 DTO 클래스입니다. + */ +@Data +public class LoginDto { + + @NotBlank(message = "아이디는 필수입니다.") + private String username; + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java b/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java new file mode 100644 index 0000000..6d1d33a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/dto/SignupDto.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.dto; + +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.enums.Role; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * @file SignupDto.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 회원가입 요청에 사용되는 DTO 클래스입니다. + */ +@Data +public class SignupDto { + + + @NotBlank(message = "아이디는 필수입니다.") + @Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다.") + @Pattern( regexp = "^[a-zA-Z0-9]+$", message = "아이디는 영문과 숫자만 사용할 수 있습니다." ) + private String username; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 20, message = "비밀번호는 8~20자여야 합니다.") + @Pattern( regexp = "^(?=.*[A-Za-z])(?=.*\\d).*$", message = "비밀번호는 영문과 숫자를 포함해야 합니다." ) + private String password; + + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 30, message = "이름은 30자를 초과할 수 없습니다.") + private String name; + + public User toEntity(String encodedPassword) { + return User.builder() + .username(username) + .password(encodedPassword) + .name(name) + .provider("NONE") + .role(Role.ROLE_USER) + .build(); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java new file mode 100644 index 0000000..c81d563 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/entity/RefreshToken.java @@ -0,0 +1,34 @@ +package com.ureka.techpost.domain.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +/** + * @file RefreshToken.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 리프레시 토큰 정보를 담는 Redis Entity 클래스입니다. + */ +@Getter +@Builder +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 1209600) +public class RefreshToken { + + @Id + private String id; // Redis Key (일반적으로 username이나 userId 사용) + + @Indexed + private String tokenValue; // 리프레시 토큰 값 (조회용 인덱스) + + private String username; // 사용자 식별자 + + public void updateToken(String tokenValue) { + this.tokenValue = tokenValue; + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java b/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java new file mode 100644 index 0000000..c29eb18 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/exception/InvalidTokenException.java @@ -0,0 +1,14 @@ +package com.ureka.techpost.domain.auth.exception; + +/** + * @file InvalidTokenException.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 유효하지 않은 토큰과 관련된 오류 상황에서 발생하는 사용자 정의 런타임 예외(Custom Exception) 클래스입니다. + */ +public class InvalidTokenException extends RuntimeException { + public InvalidTokenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..b2b6c2b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ureka.techpost.global.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +/** + * @file CustomAuthenticationEntryPoint.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-10 + @description 이 파일은 전역 예외 형식에 맞게 예외를 보내주는 파일입니다. error 403 + */ +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(HttpStatus.FORBIDDEN) + .code("ACCESS_DENIED") + .message("접근 권한이 없습니다.") + .build(); + + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..191bb3d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,42 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ureka.techpost.global.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +/** + * @file CustomAuthenticationEntryPoint.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-10 + @description 이 파일은 전역 예외 형식에 맞게 예외를 보내주는 파일입니다. error 401 + */ +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(HttpStatus.UNAUTHORIZED) + .code("AUTHENTICATION_FAILED") + .message("인증에 실패했습니다.") + .build(); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java new file mode 100644 index 0000000..ff43b22 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/CustomAuthenticationFailureHandler.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ureka.techpost.global.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * @file CustomAuthenticationFailureHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-10 + @description 이 파일은 전역 예외 형식에 맞게 예외를 보내주는 파일입니다. error 401 + */ + +@Component +public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(HttpStatus.UNAUTHORIZED) + .code("LOGIN_FAILED") + .message("로그인에 실패했습니다.") + .build(); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java new file mode 100644 index 0000000..cf6b890 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/JwtGlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.ureka.techpost.domain.auth.dto.ErrorResponseDto; +import com.ureka.techpost.domain.auth.exception.InvalidTokenException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * @file JwtGlobalExceptionHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 애플리케이션 전역에서 발생하는 예외(토큰 오류, 로그인 실패 등)를 감지하여 표준화된 에러 응답(JSON)으로 변환해주는 글로벌 예외 처리 핸들러입니다. + */ +@RestControllerAdvice +public class JwtGlobalExceptionHandler { + + @ExceptionHandler(InvalidTokenException.class) + public ResponseEntity handleInvalidTokenException(InvalidTokenException ex) { + return ErrorResponseDto.toResponseEntity(HttpStatus.UNAUTHORIZED.value(), "토큰 오류", ex.getMessage()); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { + return ErrorResponseDto.toResponseEntity(HttpStatus.UNAUTHORIZED.value(), "로그인 실패", "비밀번호가 일치하지 않습니다."); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + // "이미 가입되어 있는 회원입니다." 와 같은 회원가입 시의 예외를 처리 + if ("이미 가입되어 있는 회원입니다.".equals(ex.getMessage())) { + return ErrorResponseDto.toResponseEntity(HttpStatus.CONFLICT.value(), "회원가입 오류", ex.getMessage()); + } + // 그 외 다른 런타임 예외는 일반적인 서버 오류로 처리 + return ErrorResponseDto.toResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 오류", "알 수 없는 런타임 오류가 발생했습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..7e46c61 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,61 @@ +package com.ureka.techpost.domain.auth.handler; + + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.service.TokenService; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +/** + * @file OAuth2LoginSuccessHandler.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 소셜 로그인 성공 로직을 수행하는 클래스입니다. + */ +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final TokenService tokenService; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + CustomUserDetails oAuth2User = (CustomUserDetails) authentication.getPrincipal(); + + // 우리 시스템의 JWT 토큰 생성 + String access = jwtUtil.generateAccessToken("access", oAuth2User.getUsername(), oAuth2User.getUser().getRoleName()); + String refresh = jwtUtil.generateRefreshToken("refresh"); + + // 리프레시 토큰 저장 및 쿠키에 추가 + User user = userRepository.findByUsername(oAuth2User.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.")); + tokenService.addRefreshToken(user, refresh); + response.addCookie(tokenService.createCookie("refresh", refresh)); + + // 액세스 토큰을 쿼리 파라미터에 담아 프론트엔드 URL로 리다이렉트 + // vue.js 에서 지원하는 포트 번호로 변경해야 함 + String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:5173/") + .queryParam("accessToken", access) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java new file mode 100644 index 0000000..e7a6620 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/GoogleUserInfo.java @@ -0,0 +1,44 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file GoogleUserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 구글 사용자 정보를 추출하기 위한 구현체 클래스입니다. + */ +public class GoogleUserInfo implements OAuth2UserInfo { + + private final Map attributes; + + public GoogleUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getProviderId() { + return (String) attributes.get("sub"); + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java new file mode 100644 index 0000000..674769a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/KakaoUserInfo.java @@ -0,0 +1,49 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file KakaoUserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 카카오 사용자 정보를 추출하기 위한 구현체 클래스입니다. + */ +public class KakaoUserInfo implements OAuth2UserInfo { + + private final Map attributes; + private final Map kakaoAccountAttributes; + private final Map profileAttributes; + + @SuppressWarnings("unchecked") + public KakaoUserInfo(Map attributes) { + this.attributes = attributes; + this.kakaoAccountAttributes = (Map) attributes.get("kakao_account"); + this.profileAttributes = (Map) kakaoAccountAttributes.get("profile"); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getEmail() { + return (String) kakaoAccountAttributes.get("email"); + } + + @Override + public String getName() { + return (String) profileAttributes.get("nickname"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java new file mode 100644 index 0000000..9783e9b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/NaverUserInfo.java @@ -0,0 +1,48 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file NaverUserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 네이버 사용자 정보를 추출하기 위한 구현체 클래스입니다. + */ +public class NaverUserInfo implements OAuth2UserInfo { + + private final Map attributes; + private final Map responseAttributes; + + @SuppressWarnings("unchecked") + public NaverUserInfo(Map attributes) { + this.attributes = attributes; + // Naver 응답의 실제 사용자 정보는 "response" 키 값에 Map 형태로 들어있음 + this.responseAttributes = (Map) attributes.get("response"); + } + + @Override + public Map getAttributes() { + return responseAttributes; // 실제 속성 맵 반환 + } + + @Override + public String getProviderId() { + return (String) responseAttributes.get("id"); + } + + @Override + public String getProvider() { + return "naver"; + } + + @Override + public String getEmail() { + return (String) responseAttributes.get("email"); + } + + @Override + public String getName() { + return (String) responseAttributes.get("name"); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java b/src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java new file mode 100644 index 0000000..81d778c --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/info/OAuth2UserInfo.java @@ -0,0 +1,23 @@ +package com.ureka.techpost.domain.auth.info; + +import java.util.Map; + +/** + * @file OAuth2UserInfo.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 소셜 사용자 정보를 공통된 형식으로 사용하기 위한 클래스입니다. + */ +public interface OAuth2UserInfo { + // 제공자로부터 받은 원본 사용자 정보 + Map getAttributes(); + // 제공자의 고유 식별 ID + String getProviderId(); + // 제공자 이름 (google, naver, kakao) + String getProvider(); + // 사용자 이메일 + String getEmail(); + // 사용자 이름 + String getName(); +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3718865 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,98 @@ +package com.ureka.techpost.domain.auth.jwt; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import com.ureka.techpost.domain.auth.service.TokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * @file JwtAuthenticationFilter.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 모든 API 요청이 올 때마다 가장 먼저 실행되어 토큰 검사하는 클래스입니다. + */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final TokenService tokenService; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + // reissue 요청은 헤더에 access 토큰이 아닌 refresh 토큰이 필요하기 때문에, + // JwtAuthenticationFilter의 검증 로직을 건너뛰어야 함 + return requestURI.equals("/api/auth/reissue"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + log.info("[JwtAuthFilter] doFilterInternal"); + + // 요청 헤더에서 Authorization 키의 값(토큰) 추출 + String authorization = request.getHeader("Authorization"); + + // 토큰이 없거나, Bearer 타입이 아니면 필터 통과 (인증 실패 처리됨) + if (authorization == null || !authorization.startsWith("Bearer ")) { + log.warn("JWT 토큰 없음"); + filterChain.doFilter(request, response); + return; + } + + // "Bearer " 접두사를 제거하고 순수 토큰 값만 추출 + String accessToken = authorization.split(" ")[1]; + + // 토큰 유효성 검증 (만료 여부, 위조 여부 등 확인) + // 유효하지 않으면 예외가 발생하여 GlobalExceptionHandler가 처리 + tokenService.validateAccessToken(accessToken); + + // 토큰에서 사용자 이름(username) 추출 + String username = jwtUtil.getUsernameFromToken(accessToken); + + // 추출한 username으로 DB에서 실제 사용자 정보 조회 + // (토큰에는 비밀번호 같은 민감한 정보가 없으므로 DB 조회가 필요할 수 있음) + User foundUser = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다.")); + + // 인증 객체(Authentication) 생성을 위한 임시 User 객체 생성 + // 비밀번호는 이미 토큰 검증을 통과했으므로 임의의 값으로 설정 + User user = User.builder() + .userId(foundUser.getUserId()) + .username(username) + .password("temppassword") + .name(foundUser.getName()) + .role(foundUser.getRole()) + .provider("NONE") + .providerId(null) + .build(); + + // UserDetails 객체 생성 (Spring Security가 사용하는 사용자 정보 객체) + CustomUserDetails customUserDetails = new CustomUserDetails(user); + + // 스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + // 세션(Security Context)에 인증 정보 등록 + // 이 요청이 끝날 때까지만 인증된 상태로 유지됨 (Stateless) + SecurityContextHolder.getContext().setAuthentication(authToken); + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..1fa875d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/jwt/JwtUtil.java @@ -0,0 +1,101 @@ +package com.ureka.techpost.domain.auth.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * @file JwtUtil.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 JWT의 생성, 검증, 만료 확인, 정보 추출(파싱) 등을 담당하는 유틸리티 컴포넌트입니다. + */ +@Slf4j +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expiration-time}") + private long ACCESS_TOKEN_EXPIRE_TIME; + + @Value("${jwt.refresh-token-expiration-time}") + private long REFRESH_TOKEN_EXPIRE_TIME; + + // 토큰 암호화 키 + private SecretKey key; + + // application.yml에서 jwt.secret 값을 가져와서 비밀 키로 세팅 + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + // Access 토큰 생성 메소드 + // username, role, category 담겨있음 + public String generateAccessToken(String category, String username, String role) { + return Jwts.builder() + .subject(username) + .claim("role", role) + .claim("category", category) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME)) + .signWith(key) + .compact(); + } + + // Refresh 토큰 생성 메소드 + // category만 담겨있음 + public String generateRefreshToken(String category) { + return Jwts.builder() + .claim("category", category) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(key) + .compact(); + } + + // JWT로부터 subject를 꺼내서 username 확인 + public String getUsernameFromToken(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject(); + } + + // JWT로부터 role claim 추출 + public String getRoleFromToken(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + // JWT로부터 category 추출 (access, refresh 구분) + public String getCategory(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("category", String.class); + } + + // 토큰이 만료되었으면 true, 아니면 false + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token) + .getPayload().getExpiration().before(new Date()); + } + + // 만료된 토큰에서 username 추출 + public String getUsernameFromExpirationToken(String token) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } catch (ExpiredJwtException e) { + // 만료된 토큰이어도 일단 내부 정보 반환(재발급 시 사용자 정보가 필요할 수 있음) + return e.getClaims().getSubject(); + } + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..a30fb0a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,27 @@ +package com.ureka.techpost.domain.auth.repository; + +import com.ureka.techpost.domain.auth.entity.RefreshToken; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * @file RefreshTokenRepository.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 RefreshToken Entity를 위한 Redis Repository 클래스 입니다. + */ +@Repository +public interface RefreshTokenRepository extends CrudRepository { + + // @Indexed로 지정된 필드는 findBy 구문으로 조회 가능 + Optional findByTokenValue(String tokenValue); + + // CrudRepository는 기본적으로 Key(@Id) 기반 조회만 빠르고, Indexed 필드 조회는 보조 인덱스를 사용함. + + Optional findByUsername(String username); + + void deleteByTokenValue(String tokenValue); +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java new file mode 100644 index 0000000..2ad004b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/AuthService.java @@ -0,0 +1,174 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.dto.LoginDto; +import com.ureka.techpost.domain.auth.dto.SignupDto; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Iterator; + +/** + * @file AuthController.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 인증 관련 로직을 수행하는 클래스입니다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final TokenService tokenService; + private final AuthenticationManager authenticationManager; + + // 회원가입 + @Transactional + public void signup(SignupDto signupDto) { + // DB에 입력한 username이 존재하는지 확인 + if (userRepository.existsByUsername(signupDto.getUsername())) { + throw new CustomException(ErrorCode.USER_ALREADY_EXISTS); + } + + // 없으면 DB에 회원 저장 + User user = signupDto.toEntity(passwordEncoder.encode(signupDto.getPassword())); + userRepository.save(user); + } + + public void login(LoginDto loginDto, HttpServletResponse response) { + // 입력 데이터에서 username, password 꺼냄 + String username = loginDto.getUsername(); + String password = loginDto.getPassword(); + + // 로그인을 위한 Spring Security 인증 토큰 생성 + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); + + // AuthenticationManager를 통해 사용자 인증 시도 + // 인증 성공 시, 사용자 정보(Principal)와 권한(Authorities)을 포함한 Authentication 객체 반환 + Authentication authentication = authenticationManager.authenticate(authToken); + + // 인증된 사용자 이름 추출 + String authenticatedUsername = authentication.getName(); + + // 인증된 사용자의 권한(Role) 추출 + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // JWT 액세스 토큰 및 리프레시 토큰 생성 + String access = jwtUtil.generateAccessToken("access", authenticatedUsername, role); + String refresh = jwtUtil.generateRefreshToken("refresh"); + + // DB에서 사용자 정보 조회 (리프레시 토큰 저장을 위함) + User user = userRepository.findByUsername(authenticatedUsername) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 새로 발급된 리프레시 토큰을 DB에 저장 (기존 토큰이 있다면 업데이트) + tokenService.addRefreshToken(user, refresh); + + // 클라이언트 응답 헤더에 액세스 토큰 추가 (Bearer 타입) + response.setHeader("Authorization", "Bearer " + access); + // 클라이언트 응답 쿠키에 HttpOnly 리프레시 토큰 추가 + response.addCookie(tokenService.createCookie("refresh", refresh)); + // HTTP 응답 상태를 OK(200)로 설정 + response.setStatus(HttpStatus.OK.value()); + } + + // 토큰 재발급 + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { + + String authorization = request.getHeader("Authorization"); + // Access Token 검증 + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); + } + String accessToken = authorization.split(" ")[1]; + + String refresh = getRefreshTokenFromCookie(request); + + tokenService.validateRefreshToken(refresh); + + // --- 검증 통과 --- // + + // 기존 토큰에서 username 꺼냄 + String username = jwtUtil.getUsernameFromExpirationToken(accessToken); + + User foundUser = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 새로운 access/refresh 토큰 생성 + String newAccess = jwtUtil.generateAccessToken("access", username, foundUser.getRoleName()); + String newRefresh = jwtUtil.generateRefreshToken("refresh"); + + // 기존 Refresh 토큰 DB에서 삭제 후 새 Refresh 토큰 저장 + tokenService.deleteByTokenValue(refresh); + tokenService.addRefreshToken(foundUser, newRefresh); + + // 응답 설정 + response.setHeader("Authorization", "Bearer " + newAccess); + response.addCookie(tokenService.createCookie("refresh", newRefresh)); + + return new ResponseEntity<>(HttpStatus.OK); + } + + // 로그아웃 처리 + public void logout(HttpServletRequest request, HttpServletResponse response) { + String refresh = getRefreshTokenFromCookie(request); + + // 토큰이 존재하면 검증 및 DB 삭제 시도 + if (refresh != null) { + try { + // 토큰 검증 (만료, 위조, DB 존재 여부 확인) + tokenService.validateRefreshToken(refresh); + // DB에서 Refresh 토큰 제거 + tokenService.deleteByTokenValue(refresh); + } catch (CustomException e) { + // 토큰이 유효하지 않거나(만료 등), 이미 DB에 없는 경우 + // 로그아웃 과정이므로 무시하고 쿠키 삭제로 넘어감 + } + } + + // response에서 쿠키 제거 (항상 수행하여 클라이언트 상태 정리) + Cookie cookie = new Cookie("refresh", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } + + private static String getRefreshTokenFromCookie(HttpServletRequest request) { + // Refresh 토큰 검증 + String refresh = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + break; + } + } + } + return refresh; + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java b/src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..9d8e274 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,90 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.auth.info.GoogleUserInfo; +import com.ureka.techpost.domain.auth.info.KakaoUserInfo; +import com.ureka.techpost.domain.auth.info.NaverUserInfo; +import com.ureka.techpost.domain.auth.info.OAuth2UserInfo; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.enums.Role; +import com.ureka.techpost.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * @file CustomOAuth2UserService.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 소셜 사용자 정보를 DB에 위한 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + // 기본 OAuth2UserService를 통해 사용자 정보 가져오기 + OAuth2User oAuth2User = super.loadUser(userRequest); + + // 사용자의 소셜 서비스 제공자(provider) 이름 가져오기 (google, naver, kakao 등) + String provider = userRequest.getClientRegistration().getRegistrationId(); + + // 제공자에 따라 적절한 OAuth2UserInfo 구현체 선택 + OAuth2UserInfo oAuth2UserInfo = getOAuth2UserInfo(provider, oAuth2User); + + // 소셜 사용자의 고유 ID와 제공자 이름을 조합하여 유니크한 username 생성 + // 예: google_112233445566 + String username = provider + "_" + oAuth2UserInfo.getProviderId(); + + // DB에서 해당 사용자를 조회 + User existingUser = userRepository.findByUsername(username).orElse(null); + + User user; + if (existingUser != null) { + // 이미 가입된 사용자인 경우, 기존 정보를 그대로 사용 + user = existingUser; + } else { + // 신규 사용자인 경우, 자동 회원가입 진행 + String randomPassword = passwordEncoder.encode(UUID.randomUUID().toString()); + user = User.builder() + .username(username) + .name(oAuth2UserInfo.getName()) + .password(randomPassword) // 소셜 로그인이므로 비밀번호는 임의의 값으로 설정 + .role(Role.ROLE_USER) + .provider(oAuth2UserInfo.getProvider()) + .providerId(oAuth2UserInfo.getProviderId()) + .build(); + userRepository.save(user); + } + + // 우리 시스템에서 사용할 CustomUserDetails 객체로 변환하여 반환 + return new CustomUserDetails(user, oAuth2User.getAttributes()); + } + + private static OAuth2UserInfo getOAuth2UserInfo(String provider, OAuth2User oAuth2User) { + OAuth2UserInfo oAuth2UserInfo; + if (provider.equals("google")) { + oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes()); + } else if (provider.equals("naver")) { + oAuth2UserInfo = new NaverUserInfo(oAuth2User.getAttributes()); + } else if (provider.equals("kakao")) { + oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes()); + } else { + // 지원하지 않는 제공자일 경우 예외 처리 + throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다."); + } + return oAuth2UserInfo; + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java b/src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..60cf599 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/CustomUserDetailsService.java @@ -0,0 +1,33 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * @file CustomUserDetailsService.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 스프링 시큐리티 로그인 시 DB에서 사용자 정보를 조회하여 인증 객체(UserDetails)를 생성 및 반환하는 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다.")); + + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java new file mode 100644 index 0000000..a2e2823 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/auth/service/TokenService.java @@ -0,0 +1,104 @@ +package com.ureka.techpost.domain.auth.service; + +import com.ureka.techpost.domain.auth.entity.RefreshToken; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.repository.RefreshTokenRepository; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * @file TokenService.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 Refresh 토큰의 저장·삭제(Redis) 및 로그아웃, 토큰 유효성 검증 등 토큰의 전반적인 생명주기를 관리하는 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + + // DB에 Refresh 토큰 저장 (Redis) + public void addRefreshToken(User user, String refresh) { + // Redis에 저장할 객체 생성 + // @Id 필드(id)에 user.getUsername()을 사용하여, 사용자별로 하나의 리프레시 토큰만 유지하도록 할 수 있음 + // 또는 refresh 값을 id로 사용하여 다중 로그인을 허용할 수도 있음. 여기서는 username을 키로 사용. + RefreshToken refreshToken = RefreshToken.builder() + .id(user.getUsername()) // Key: username + .username(user.getUsername()) + .tokenValue(refresh) + .build(); + + refreshTokenRepository.save(refreshToken); + } + + // 쿠키 생성 + public Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24 * 60 * 60); + cookie.setHttpOnly(true); + return cookie; + } + + // DB에 Refresh 토큰이 존재하는지 확인 (Redis) + public Boolean existsByTokenValue(String tokenValue) { + // @Indexed 된 필드로 조회 + return refreshTokenRepository.findByTokenValue(tokenValue).isPresent(); + } + + // DB에서 Refresh 토큰을 삭제 (Redis) + public void deleteByTokenValue(String tokenValue) { + refreshTokenRepository.deleteByTokenValue(tokenValue); + } + + // 리프레시 토큰 검증 + public void validateRefreshToken(String token) { + if (token == null) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_MISSING); + } + + try { + jwtUtil.isExpired(token); + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED); + } + + String category = jwtUtil.getCategory(token); + if (!category.equals("refresh")) { + throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); + } + + if (!existsByTokenValue(token)) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + } + + // 액세스 토큰 검증 + public void validateAccessToken(String token) { + if (token == null) { + throw new CustomException(ErrorCode.ACCESS_TOKEN_MISSING); + } + + try { + jwtUtil.isExpired(token); + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.ACCESS_TOKEN_EXPIRED); + } + + String category = jwtUtil.getCategory(token); + if (!category.equals("access")) { + throw new CustomException(ErrorCode.INVALID_TOKEN_CATEGORY); + } + + } + +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..a2231f8 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/ChatController.java @@ -0,0 +1,61 @@ +/** + * @file ChatController.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 관련 컨트롤러 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.controller; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.chat.dto.response.ChatMessageRes; +import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; +import com.ureka.techpost.domain.chat.service.ChatService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import java.util.List; + +import com.ureka.techpost.global.apiPayload.code.BaseCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/chats") +public class ChatController { + private final ChatService chatService; + + @GetMapping + public ApiResponse> getChatRoomList() { + return ApiResponse.onSuccess(chatService.getChatRoomList()); + } + + @PostMapping + public ApiResponse createGroupChatRoom(@AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam String roomName) { + chatService.createGroupChatRoom(roomName, userDetails); + return ApiResponse.onSuccess(null); + } + + @GetMapping("/history/{roomId}") + public ApiResponse> getChatHistory(@AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long roomId) { + return ApiResponse.onSuccess(chatService.getChatHistory(roomId, userDetails)); + } + + @GetMapping("/my") + public ApiResponse> getMyChatRoomList(@AuthenticationPrincipal CustomUserDetails userDetails) { + return ApiResponse.onSuccess(chatService.getMyChatRoomList(userDetails)); + } + + @PostMapping("/{roomId}/join") + public ApiResponse joinChatRoom(@AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long roomId) { + chatService.joinChatRoom(userDetails, roomId); + return ApiResponse.onSuccess(null); + } + + @DeleteMapping("/{roomId}/leave") + public ApiResponse leaveChatRoom(@AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long roomId) { + chatService.leaveChatRoom(userDetails, roomId); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java new file mode 100644 index 0000000..8534674 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/controller/StompController.java @@ -0,0 +1,38 @@ +/** + * @file StompController.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 메시지 전송을 위한 컨트롤러 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; +import com.ureka.techpost.domain.chat.service.ChatService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; + +@RequiredArgsConstructor +@Controller +public class StompController { + + private final SimpMessageSendingOperations messageTemplate; + private final ChatService chatService; + + @MessageMapping("/{roomId}") + public void sendMessage(@DestinationVariable Long roomId, @AuthenticationPrincipal CustomUserDetails userDetails, @Payload @Valid ChatMessageReq chatMessageReq) { + Long userId = userDetails.getUser().getUserId(); + + chatService.saveMessage(roomId, userId, chatMessageReq); + messageTemplate.convertAndSend("/topic/" + roomId, chatMessageReq); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java b/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java new file mode 100644 index 0000000..4697d80 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/request/ChatMessageReq.java @@ -0,0 +1,24 @@ +/** + * @file ChatMessageReq.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 메시지 전송 시 사용되는 Request Dto 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +public class ChatMessageReq { + + private String message; + private String senderName; +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java new file mode 100644 index 0000000..80d7b0a --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatMessageRes.java @@ -0,0 +1,22 @@ +/** + * @file ChatMessageRes.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-09 + @description 이 파일은 채팅 내역 불러오기 시 사용되는 Response Dto 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Builder +@Getter +public class ChatMessageRes { + + private String message; + private String senderName; +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java new file mode 100644 index 0000000..3de401e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/dto/response/ChatRoomRes.java @@ -0,0 +1,32 @@ +/** + * @file ChatRoomRes.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅방 목록 조회 시 사용되는 Response Dto 클래스입니다. + */ + + +package com.ureka.techpost.domain.chat.dto.response; + +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Builder +@Getter +public class ChatRoomRes { + + private Long roomId; + + private String roomName; + + public static ChatRoomRes from(ChatRoom chatRoom) { + return ChatRoomRes.builder() + .roomId(chatRoom.getId()) + .roomName(chatRoom.getRoomName()) + .build(); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java new file mode 100644 index 0000000..bfad5b8 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatMessage.java @@ -0,0 +1,46 @@ +/** + * @file ChatMessage.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 메시지 Jpa Entity 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.entity; + +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Entity +public class ChatMessage extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private User user; + + @Column(nullable = false, length = 500) + private String content; +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java new file mode 100644 index 0000000..423f529 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatParticipant.java @@ -0,0 +1,42 @@ +/** + * @file ChatParticipant.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 - 회원 중간 테이블 Jpa Entity 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.entity; + +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Entity +public class ChatParticipant extends BaseEntity { + @Id + @GeneratedValue(strategy= GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java new file mode 100644 index 0000000..646b372 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/entity/ChatRoom.java @@ -0,0 +1,34 @@ +/** + * @file ChatRoom.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅방 Jpa Entity 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.entity; + +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Entity +public class ChatRoom extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String roomName; +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..ccaf63d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,20 @@ +/** + * @file ChatMessageRepository.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 메시지 Repository 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.repository; + +import com.ureka.techpost.domain.chat.entity.ChatMessage; +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + List findByChatRoomOrderByCreatedAtAsc(ChatRoom chatRoom); // 생성 시간 오름차순으로 정렬 +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java new file mode 100644 index 0000000..0d888cd --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatParticipantRepository.java @@ -0,0 +1,26 @@ +package com.ureka.techpost.domain.chat.repository; + +import com.ureka.techpost.domain.chat.entity.ChatParticipant; +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import com.ureka.techpost.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ChatParticipantRepository extends JpaRepository { + boolean existsByChatRoom_IdAndUser_UserId(Long chatRoomId, Long userId); + + @Query("SELECT cp FROM ChatParticipant cp " + + "JOIN FETCH cp.chatRoom " + + "WHERE cp.user.userId = :userId") + List findAllWithChatRoomByUserId(@Param("userId") Long userId); + + Optional findByUserAndChatRoom(User user, ChatRoom chatRoom); + + boolean existsByUserAndChatRoom(User user, ChatRoom chatRoom); +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..7039438 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,17 @@ +/** + * @file ChatRoomRepository.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅방 Repository 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.repository; + +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatRoomRepository extends JpaRepository { +} diff --git a/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java new file mode 100644 index 0000000..6c01dc6 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/chat/service/ChatService.java @@ -0,0 +1,141 @@ +/** + * @file ChatService.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 채팅 관련 서비스 클래스입니다. + */ + +package com.ureka.techpost.domain.chat.service; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.chat.dto.request.ChatMessageReq; +import com.ureka.techpost.domain.chat.dto.response.ChatMessageRes; +import com.ureka.techpost.domain.chat.dto.response.ChatRoomRes; +import com.ureka.techpost.domain.chat.entity.ChatMessage; +import com.ureka.techpost.domain.chat.entity.ChatParticipant; +import com.ureka.techpost.domain.chat.entity.ChatRoom; +import com.ureka.techpost.domain.chat.repository.ChatMessageRepository; +import com.ureka.techpost.domain.chat.repository.ChatParticipantRepository; +import com.ureka.techpost.domain.chat.repository.ChatRoomRepository; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class ChatService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final UserRepository userRepository; + + public List getChatRoomList() { + List chatRoomList = chatRoomRepository.findAll(); + List chatRoomResList = new ArrayList<>(); + + for (ChatRoom chatRoom : chatRoomList) + chatRoomResList.add(ChatRoomRes.from(chatRoom)); + + return chatRoomResList; + } + + @Transactional + public void saveMessage(Long roomId, Long userId, ChatMessageReq chatMessageReq) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + + User sender = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + + ChatMessage chatMessage = ChatMessage.builder() + .chatRoom(chatRoom) + .user(sender) + .content(chatMessageReq.getMessage()) + .build(); + + chatMessageRepository.save(chatMessage); + } + + @Transactional + public void createGroupChatRoom(String roomName, CustomUserDetails userDetails) { + Long userId = userDetails.getUser().getUserId(); + User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + + ChatRoom chatRoom = ChatRoom.builder() + .roomName(roomName) + .build(); + chatRoomRepository.save(chatRoom); + + ChatParticipant chatParticipant = ChatParticipant.builder() + .chatRoom(chatRoom) + .user(user) + .build(); + chatParticipantRepository.save(chatParticipant); + } + + public List getChatHistory(Long roomId, CustomUserDetails userDetails) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + + Long userId = userDetails.getUser().getUserId(); + + boolean isParticipant = chatParticipantRepository.existsByChatRoom_IdAndUser_UserId(roomId, userId); + if(!isParticipant) throw new IllegalArgumentException("속하지 않은 채팅방입니다."); + + List chatMessages = chatMessageRepository.findByChatRoomOrderByCreatedAtAsc(chatRoom); + List chatMessageResList = new ArrayList<>(); + + for (ChatMessage chatMessage : chatMessages) { + ChatMessageRes chatMessageRes = ChatMessageRes.builder() + .message(chatMessage.getContent()) + .senderName(chatMessage.getUser().getName()) + .build(); + chatMessageResList.add(chatMessageRes); + } + + return chatMessageResList; + } + + public List getMyChatRoomList(CustomUserDetails userDetails) { + return chatParticipantRepository.findAllWithChatRoomByUserId(userDetails.getUser().getUserId()) + .stream() + .map(chatParticipant -> ChatRoomRes.from(chatParticipant.getChatRoom())) + .toList(); + } + + @Transactional + public void joinChatRoom(CustomUserDetails userDetails, Long roomId) { + User user = userRepository.findById(userDetails.getUser().getUserId()) + .orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + + if (chatParticipantRepository.existsByUserAndChatRoom(user, chatRoom)) + throw new IllegalStateException("이미 참여 중인 채팅방입니다"); + + ChatParticipant chatParticipant = ChatParticipant.builder() + .chatRoom(chatRoom) + .user(user) + .build(); + chatParticipantRepository.save(chatParticipant); + } + + @Transactional + public void leaveChatRoom(CustomUserDetails userDetails, Long roomId) { + User user = userRepository.findById(userDetails.getUser().getUserId()) + .orElseThrow(() -> new EntityNotFoundException("user cannot be found")); + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new EntityNotFoundException("room cannot be found")); + ChatParticipant chatParticipant = chatParticipantRepository.findByUserAndChatRoom(user, chatRoom) + .orElseThrow(() -> new EntityNotFoundException("chat participant cannot be found")); + + chatParticipantRepository.delete(chatParticipant); + } +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..be7f8d6 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/controller/CommentController.java @@ -0,0 +1,75 @@ +package com.ureka.techpost.domain.comment.controller; + + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.comment.dto.CommentRequestDTO; +import com.ureka.techpost.domain.comment.dto.CommentResponseDTO; +import com.ureka.techpost.domain.comment.service.CommentService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import com.ureka.techpost.global.apiPayload.code.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @file CommentController.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 댓글 관련 API 요청(추가, 조회, 수정, 삭제)을 받아 서비스 계층으로 전달하고, 처리 결과를 클라이언트에게 반환하는 컨트롤러 클래스입니다. + */ + +@Tag(name = "댓글(Comment) API", description = "게시글 댓글 관련 API") +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 작성", description = "특정 게시글(postId)에 새로운 댓글을 작성합니다.") + @PostMapping("/posts/{postId}/comments") + public ApiResponse createComment(@Parameter(description = "댓글을 달 게시글의 ID") @PathVariable Long postId, + @RequestBody CommentRequestDTO commentRequestDTO, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + commentService.createComment(commentRequestDTO, userDetails, postId); + + return ApiResponse.of(SuccessStatus._CREATED, null); + } + + @Operation(summary = "댓글 목록 조회", description = "특정 게시글(postId)에 달린 모든 댓글 목록을 조회합니다.") + @GetMapping("/posts/{postId}/comments") + public ApiResponse> getComments(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ + + List commentResponseList = commentService.findByPostId(postId); + + return ApiResponse.onSuccess(commentResponseList); + } + + @Operation(summary = "댓글 삭제", description = "댓글 ID를 이용하여 본인이 작성한 댓글을 삭제합니다.") + @DeleteMapping("/comments/{commentId}") + public ApiResponse deleteComment(@Parameter(description = "삭제할 댓글의 ID") @PathVariable Long commentId, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + commentService.deleteComment(commentId, userDetails); + + return ApiResponse.of(SuccessStatus._NO_CONTENT, null); + } + + @Operation(summary = "댓글 수정", description = "댓글 ID를 이용하여 본인이 작성한 댓글 내용을 수정합니다.") + @PatchMapping("/comments/{commentId}") + public ApiResponse patchComment(@Parameter(description = "수정할 댓글의 ID") @PathVariable Long commentId, + @RequestBody CommentRequestDTO commentRequestDTO, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + commentService.patchComment(commentId, userDetails, commentRequestDTO); + + return ApiResponse.of(SuccessStatus._NO_CONTENT, null); + } + +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java new file mode 100644 index 0000000..af80757 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentRequestDTO.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * @file CommentRequestDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 클라이언트로부터 댓글 작성 및 수정 요청이 들어올 때, 해당 내용을 전달받기 위해 사용하는 요청 DTO 클래스입니다. + */ + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class CommentRequestDTO { + private String content; +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java new file mode 100644 index 0000000..bf5e651 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/dto/CommentResponseDTO.java @@ -0,0 +1,23 @@ +package com.ureka.techpost.domain.comment.dto; + +import lombok.*; + +/** + * @file CommentResponseDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 댓글 목록 조회 API 호출 시, 댓글 정보(ID, 작성자, 내용 등)를 클라이언트에게 반환하기 위해 사용하는 응답 DTO 클래스입니다. + */ + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class CommentResponseDTO { + private Long id; + private Long userId; + private String userName; + private String content; +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java b/src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java new file mode 100644 index 0000000..1e6b93d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/entity/Comment.java @@ -0,0 +1,51 @@ +package com.ureka.techpost.domain.comment.entity; + +import com.ureka.techpost.domain.post.entity.Post; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @file Comment.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 게시글에 달리는 댓글 정보를 관리하며, 데이터베이스의 'Comment' 테이블과 매핑되는 엔티티 클래스입니다. + */ + +@Entity +@Table(name = "Comment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @Column + private String content; + + public void updateContent(String content){ + this.content = content; + } + + @Builder + public Comment(User user, Post post, String content){ + this.user = user; + this.post = post; + this.content = content; + } +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java b/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..8c7aa30 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/repository/CommentRepository.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.comment.repository; + + +import com.ureka.techpost.domain.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * @file CommentRepository.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 댓글(Comment) 엔티티의 데이터베이스 CRUD 작업을 담당하며, N+1 문제 해결을 위한 Fetch Join 쿼리를 포함하는 리포지토리 인터페이스입니다. + */ + +public interface CommentRepository extends JpaRepository { + @Query("select c from Comment c join fetch c.user where c.post.id = :postId") + List findAllByPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java b/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java new file mode 100644 index 0000000..b38157d --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/comment/service/CommentService.java @@ -0,0 +1,104 @@ +package com.ureka.techpost.domain.comment.service; + + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.comment.dto.CommentRequestDTO; +import com.ureka.techpost.domain.comment.dto.CommentResponseDTO; +import com.ureka.techpost.domain.comment.entity.Comment; +import com.ureka.techpost.domain.comment.repository.CommentRepository; +import com.ureka.techpost.domain.post.entity.Post; +import com.ureka.techpost.domain.post.repository.PostRepository; +import com.ureka.techpost.domain.user.entity.User; +import com.ureka.techpost.domain.user.repository.UserRepository; +import com.ureka.techpost.global.exception.CustomException; +import com.ureka.techpost.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * @file CommentService.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-10 + * @description 댓글 작성, 수정, 삭제, 조회 등 댓글 도메인의 핵심 비즈니스 로직을 처리하고 트랜잭션을 관리하는 서비스 클래스입니다. + */ + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + + @Transactional + public void createComment(CommentRequestDTO commentRequestDTO, CustomUserDetails userDetails, Long postId){ + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = Comment.builder() + .user(user) + .post(post) + .content(commentRequestDTO.getContent()) + .build(); + + commentRepository.save(comment); + } + + @Transactional(readOnly = true) + public List findByPostId(Long postId) { + + if(!postRepository.existsById(postId)){ + throw new CustomException(ErrorCode.POST_NOT_FOUND); + } + + return commentRepository.findAllByPostId(postId).stream() + .map(comment -> new CommentResponseDTO( + comment.getId() + , comment.getUser().getUserId() + , comment.getUser().getName() + , comment.getContent() + )) + .collect(Collectors.toList()); + } + + @Transactional + public void patchComment(Long commentId, CustomUserDetails userDetails, CommentRequestDTO commentRequestDTO) { + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + if(!comment.getUser().getUserId().equals(user.getUserId())){ + throw new CustomException(ErrorCode.USER_NOT_MATCH); + } + + comment.updateContent(commentRequestDTO.getContent()); + } + + @Transactional + public void deleteComment(Long commentId, CustomUserDetails userDetails) { + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + if(!comment.getUser().getUserId().equals(user.getUserId())){ + throw new CustomException(ErrorCode.USER_NOT_MATCH); + } + + commentRepository.delete(comment); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java new file mode 100644 index 0000000..5320e03 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/controller/PostController.java @@ -0,0 +1,74 @@ +package com.ureka.techpost.domain.post.controller; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import com.ureka.techpost.domain.post.dto.PostRequestDTO; +import com.ureka.techpost.domain.post.service.PostService; +import com.ureka.techpost.global.apiPayload.ApiResponse; +import com.ureka.techpost.global.apiPayload.code.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * @file PostController.java + @author 최승언 + @version 1.0 + @since 2025-12-09 + @description 게시글 관련 API 요청(생성, 조회, 검색, 삭제)을 받아 서비스 계층으로 전달하고 응답을 반환하는 컨트롤러 클래스입니다. + */ + +@Tag(name = "게시글(Post) API", description = "게시글 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +public class PostController { + + private final PostService postService; + + @Operation(summary = "게시글 등록", description = "제목, 내용, 링크 등을 받아 게시글을 등록합니다.") + @PostMapping("") + public ApiResponse createPost(@RequestBody PostRequestDTO postRequestDTO, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + postService.save(postRequestDTO, userDetails); + + return ApiResponse.of(SuccessStatus._CREATED, null); + } + + @Operation(summary = "게시글 목록 조회/검색", description = "키워드와 출처로 검색하거나 전체 목록을 페이징하여 조회합니다.") + @GetMapping("") + public ApiResponse> searchPosts( + @Parameter(description = "검색할 키워드 (제목/요약)") @RequestParam(required = false) String keyword, + @Parameter(description = "출처 필터링 (예: Velog)") @RequestParam(required = false) String publisher, + @ParameterObject @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC)Pageable pageable + ){ + return ApiResponse.onSuccess(postService.search(keyword, publisher, pageable)); + } + + @Operation(summary = "게시글 상세 조회", description = "게시글 ID(PK)를 이용하여 특정 게시글의 상세 정보를 조회합니다.") + @GetMapping("/{postId}") + public ApiResponse getPost(@Parameter(description = "조회할 게시글의 ID") @PathVariable Long postId){ + + return ApiResponse.onSuccess(postService.findById(postId)); + } + + @Operation(summary = "게시글 삭제", description = "게시글 ID와 로그인한 유저 정보를 비교하여 본인의 게시글을 삭제합니다.") + @DeleteMapping("/{postId}") + public ApiResponse deletePost(@Parameter(description = "삭제할 게시글의 ID") @PathVariable Long postId, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails){ + + postService.deletePost(postId, userDetails); + + return ApiResponse.of(SuccessStatus._NO_CONTENT, null); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java b/src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java new file mode 100644 index 0000000..943d02e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/dto/PostRequestDTO.java @@ -0,0 +1,32 @@ +package com.ureka.techpost.domain.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * @file PostRequestDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 클라이언트로부터 게시글 등록 요청 시 전달받는 데이터를 담는 DTO 클래스입니다. + */ + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PostRequestDTO { + + private String title; + private String summary; + private String originalUrl; + private String thumbnailUrl; + private String publisher; + private LocalDateTime publishedAt; + private String sourceName; + +} diff --git a/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java b/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java new file mode 100644 index 0000000..8e0d53e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/dto/PostResponseDTO.java @@ -0,0 +1,35 @@ +package com.ureka.techpost.domain.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * @file PostResponseDTO.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 클라이언트에게 게시글 정보를 응답(Response)할 때 사용하는 데이터를 담는 DTO 클래스입니다. + */ + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PostResponseDTO { + private Long id; + private String title; + private String summary; + private String originalUrl; + private String thumbnailUrl; + private String publisher; + private LocalDateTime publishedAt; + private String sourceName; + private LocalDateTime createdAt; + + private Long likeCount; + private Long commentCount; +} diff --git a/src/main/java/com/ureka/techpost/domain/post/entity/Post.java b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java new file mode 100644 index 0000000..9cacfcb --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/entity/Post.java @@ -0,0 +1,61 @@ +package com.ureka.techpost.domain.post.entity; + +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * @file Post.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 게시글의 핵심 데이터(제목, 내용, URL 등)를 관리하는 엔티티(Entity) 클래스입니다. + */ + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(columnDefinition = "TEXT", nullable = false) + private String summary; + + @Column(name = "original_url", nullable = false, unique = true) + private String originalUrl; + + @Column(nullable = false) + private String publisher; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Column(name = "source_name") + private String sourceName; + + @Column(name = "thumbnail_url", nullable = false) + private String thumbnailUrl; + + @Builder + public Post(String title, String summary, String originalUrl, String publisher, LocalDateTime publishedAt, String sourceName, String thumbnailUrl) { + this.title = title; + this.summary = summary; + this.originalUrl = originalUrl; + this.publisher = publisher; + this.publishedAt = publishedAt; + this.sourceName = sourceName; + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..544a31e --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepository.java @@ -0,0 +1,48 @@ +package com.ureka.techpost.domain.post.repository; + +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import com.ureka.techpost.domain.post.entity.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * @file PostRepository.java + * @author 최승언 + * @version 1.1 + * @since 2025-12-09 + * @description 게시글(Post) 엔티티의 기본적인 CRUD 및 JPQL 쿼리를 담당하는 JPA Repository 인터페이스입니다. + */ + +@Repository +public interface PostRepository extends JpaRepository, PostRepositoryCustom { + + boolean existsByOriginalUrl(String originalUrl); + + @Query("SELECT new com.ureka.techpost.domain.post.dto.PostResponseDTO(" + + "p.id, p.title, p.summary, p.originalUrl, p.thumbnailUrl, " + + "p.publisher, p.publishedAt, p.sourceName, p.createdAt, " + +// [수정] 좋아요 수: 아직 없으므로 0으로 대체 +// "(SELECT count(l) FROM Likes l WHERE l.post.id = p.id), " + + "0L, " + + "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + + "FROM Post p") + Page findPostList(Pageable pageable); + + @Query("SELECT new com.ureka.techpost.domain.post.dto.PostResponseDTO(" + + "p.id, p.title, p.summary, p.originalUrl, p.thumbnailUrl, " + + "p.publisher, p.publishedAt, p.sourceName, p.createdAt, " + +// [수정] 좋아요 수: 아직 없으므로 0으로 대체 +// "(SELECT count(l) FROM Likes l WHERE l.post.id = p.id), " + + "0L, " + + "(SELECT count(c) FROM Comment c WHERE c.post.id = p.id)) " + + "FROM Post p " + + "WHERE p.id = :postId") + Optional findPostById(@Param("postId") Long postId); + +} diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java new file mode 100644 index 0000000..6d31cff --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryCustom.java @@ -0,0 +1,17 @@ +package com.ureka.techpost.domain.post.repository; + +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * @file PostRepositoryCustom.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description QueryDSL을 사용한 동적 쿼리 및 검색 기능을 정의하기 위한 커스텀 Repository 인터페이스입니다. + */ + +public interface PostRepositoryCustom { + Page search(String keyword, String publisher, Pageable pageable); +} diff --git a/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java new file mode 100644 index 0000000..62f7553 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/repository/PostRepositoryImpl.java @@ -0,0 +1,101 @@ +package com.ureka.techpost.domain.post.repository; + +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.ureka.techpost.domain.post.entity.QPost.post; +import static com.ureka.techpost.domain.comment.entity.QComment.comment; +//import static com.ureka.techpost.domain.post.entity.QLikes.likes; + +/** + * @file PostRepositoryImpl.java + * @author 최승언 + * @version 1.1 + * @since 2025-12-09 + * @description QueryDSL을 활용하여 게시글 검색, 필터링 등 복잡한 조회 로직을 실제로 구현한 클래스입니다. + */ + +@RequiredArgsConstructor +public class PostRepositoryImpl implements PostRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page search(String keyword, String sourceName, Pageable pageable) { + List content = queryFactory + .select(Projections.constructor(PostResponseDTO.class, + post.id, + post.title, + post.summary, + post.originalUrl, + post.thumbnailUrl, + post.publisher, + post.publishedAt, + post.sourceName, + post.createdAt, + // 좋아요 수 + // [수정] 좋아요 수: 아직 없으므로 0으로 대체 + // ExpressionUtils.as( + // JPAExpressions.select(likes.count()) + // .from(likes) + // .where(likes.post.eq(post)), + // "likeCount"), + com.querydsl.core.types.dsl.Expressions.asNumber(0L), + + ExpressionUtils.as( + JPAExpressions.select(comment.count()) + .from(comment) + .where(comment.post.eq(post)), + "commentCount") + )) + .from(post) + .where( + titleOrSummaryContains(keyword), // 제목 or 요약 + sourceNameContains(sourceName) // 출처 + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(post.id.desc()) // 정렬 + .fetch(); + + // 카운트 쿼리 + JPAQuery countQuery = queryFactory + .select(post.count()) + .from(post) + .where( + titleOrSummaryContains(keyword), + sourceNameContains(sourceName) + ); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + // 제목 or 요약 키워드 검색 + private BooleanExpression titleOrSummaryContains(String keyword) { + if (!StringUtils.hasText(keyword)) { + return null; + } + return post.title.contains(keyword) + .or(post.summary.contains(keyword)); + } + + // 출처 검색 + private BooleanExpression sourceNameContains(String provider) { + if (!StringUtils.hasText(provider)) { + return null; + } + return post.sourceName.contains(provider); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/post/service/PostService.java b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java new file mode 100644 index 0000000..0477d5b --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/post/service/PostService.java @@ -0,0 +1,69 @@ +package com.ureka.techpost.domain.post.service; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.post.dto.PostResponseDTO; +import com.ureka.techpost.domain.post.dto.PostRequestDTO; +import com.ureka.techpost.domain.post.entity.Post; +import com.ureka.techpost.domain.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +/** + * @file PostService.java + * @author 최승언 + * @version 1.0 + * @since 2025-12-09 + * @description 게시글 등록, 조회, 검색, 삭제 등 게시글 도메인의 핵심 비즈니스 로직을 처리하는 서비스 클래스입니다. + */ + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + + public PostResponseDTO findById(Long id) { + + return postRepository.findPostById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글 없음")); + } + + public void save(PostRequestDTO postRequestDTO, CustomUserDetails userDetails) { + + if(!userDetails.getUser().getRoleName().equals("ROLE_ADMIN")){ + throw new IllegalArgumentException("권한이 없음"); + } + + if(postRepository.existsByOriginalUrl(postRequestDTO.getOriginalUrl())){ + throw new IllegalArgumentException("이미 존재하는 게시글"); + } + + postRepository.save(Post.builder() + .title(postRequestDTO.getTitle()) + .summary(postRequestDTO.getSummary()) + .originalUrl(postRequestDTO.getOriginalUrl()) + .publisher(postRequestDTO.getPublisher()) + .publishedAt(postRequestDTO.getPublishedAt()) + .sourceName(postRequestDTO.getSourceName()) + .thumbnailUrl(postRequestDTO.getThumbnailUrl()) + .build()); + } + + public Page search(String keyword, String publisher, Pageable pageable){ + return postRepository.search(keyword, publisher, pageable); + } + + public void deletePost(Long postId, CustomUserDetails userDetails) { + + if(!userDetails.getUser().getRoleName().equals("ROLE_ADMIN")){ + throw new IllegalArgumentException("권한이 없음"); + } + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시물")); + + postRepository.delete(post); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/test/controller/TestController.java b/src/main/java/com/ureka/techpost/domain/test/controller/TestController.java deleted file mode 100644 index f985adf..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/controller/TestController.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ureka.techpost.domain.test.controller; - -import com.ureka.techpost.domain.test.converter.TestConverter; -import com.ureka.techpost.domain.test.dto.TestResponse; -import com.ureka.techpost.global.apiPayload.ApiResponse; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class TestController { - - @GetMapping("/health") - public ApiResponse health(){ - return ApiResponse.onSuccess("health check"); - } - - @GetMapping("/test") - public ApiResponse testAPI(){ - - return ApiResponse.onSuccess(TestConverter.toTestDTO()); - } -} diff --git a/src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java b/src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java deleted file mode 100644 index 7fdd48a..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/converter/TestConverter.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ureka.techpost.domain.test.converter; - -import com.ureka.techpost.domain.test.dto.TestResponse; - -public class TestConverter { - - public static TestResponse.TestDTO toTestDTO(){ - return TestResponse.TestDTO.builder() - .testString("This is Test!") - .build(); - } -} diff --git a/src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java b/src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java deleted file mode 100644 index 4c75c12..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/dto/TestResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ureka.techpost.domain.test.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -public class TestResponse { - - @Builder - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class TestDTO{ - String testString; - } -} diff --git a/src/main/java/com/ureka/techpost/domain/test/entity/Test.java b/src/main/java/com/ureka/techpost/domain/test/entity/Test.java deleted file mode 100644 index 6a707aa..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/entity/Test.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ureka.techpost.domain.test.entity; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; - -@Entity -public class Test { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String name; -} diff --git a/src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java b/src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java deleted file mode 100644 index 843a723..0000000 --- a/src/main/java/com/ureka/techpost/domain/test/repository/TestRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ureka.techpost.domain.test.repository; - -import com.ureka.techpost.domain.test.entity.Test; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TestRepository extends JpaRepository { -} diff --git a/src/main/java/com/ureka/techpost/domain/user/entity/User.java b/src/main/java/com/ureka/techpost/domain/user/entity/User.java new file mode 100644 index 0000000..997d955 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/user/entity/User.java @@ -0,0 +1,48 @@ +package com.ureka.techpost.domain.user.entity; + +import com.ureka.techpost.domain.user.enums.Role; +import com.ureka.techpost.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +/** + * @file User.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 정보를 담는 Entity 클래스입니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long userId; + + @Column(unique = true) + private String username; + + private String password; // 소셜 로그인은 null 가능 + + @Column(nullable = false) + private String name; + + // 일반 로그인은 provider="NONE", providerId=null 로 설정 + private String provider; // google, kakao, naver + + @Column(name = "provider_id") + private String providerId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + public String getRoleName() { + return this.role.name(); + } +} diff --git a/src/main/java/com/ureka/techpost/domain/user/enums/Role.java b/src/main/java/com/ureka/techpost/domain/user/enums/Role.java new file mode 100644 index 0000000..23d80c7 --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/user/enums/Role.java @@ -0,0 +1,12 @@ +package com.ureka.techpost.domain.user.enums; + +/** + * @file Role.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 사용자 권한을 정의하는 Enum 클래스입니다. + */ +public enum Role { + ROLE_USER, ROLE_ADMIN +} diff --git a/src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java b/src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..c8ab3cd --- /dev/null +++ b/src/main/java/com/ureka/techpost/domain/user/repository/UserRepository.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.domain.user.repository; + +import com.ureka.techpost.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * @file UserRepository.java + @author 김동혁, 구본문 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 User Entity를 위한 Repository 클래스입니다. + */ +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java b/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java index 6c274a5..26259f8 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/ApiResponse.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.ureka.techpost.global.apiPayload.code.BaseCode; -import com.ureka.techpost.global.apiPayload.code.status.SuccessStatus; +import com.ureka.techpost.global.apiPayload.code.SuccessStatus; import lombok.AllArgsConstructor; import lombok.Getter; @@ -32,12 +32,16 @@ public static ApiResponse onSuccess(T result){ return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); } + // Created 응답 생성 -> 201 + public static ApiResponse onCreated(T result) { + return of(SuccessStatus._CREATED, null); + } + // 커스텀 성공 응답 -> ex) 201 created, 202 Accepted, 204 No content public static ApiResponse of(BaseCode code, T result){ return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); } - // 실패한 경우 응답 생성 public static ApiResponse onFailure(String code, String message, T data){ return new ApiResponse<>(false, code, message, data); diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java deleted file mode 100644 index c719784..0000000 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/BaseErrorCode.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ureka.techpost.global.apiPayload.code; - -public interface BaseErrorCode { - - ErrorReasonDTO getReason(); - - ErrorReasonDTO getReasonHttpStatus(); -} \ No newline at end of file diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java deleted file mode 100644 index 68c7475..0000000 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/ErrorReasonDTO.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ureka.techpost.global.apiPayload.code; - -import lombok.Builder; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@Builder -public class ErrorReasonDTO { - - private HttpStatus httpStatus; - - private final boolean isSuccess; - private final String code; - private final String message; - - public boolean getIsSuccess(){return isSuccess;} -} diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/SuccessStatus.java similarity index 76% rename from src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java rename to src/main/java/com/ureka/techpost/global/apiPayload/code/SuccessStatus.java index 74453c3..3b3a215 100644 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/com/ureka/techpost/global/apiPayload/code/SuccessStatus.java @@ -1,7 +1,5 @@ -package com.ureka.techpost.global.apiPayload.code.status; +package com.ureka.techpost.global.apiPayload.code; -import com.ureka.techpost.global.apiPayload.code.BaseCode; -import com.ureka.techpost.global.apiPayload.code.ReasonDTO; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -11,7 +9,9 @@ public enum SuccessStatus implements BaseCode { // 일반적인 응답 - _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _CREATED(HttpStatus.CREATED, "COMMON201", "자원이 성공적으로 생성되었습니다."), + _NO_CONTENT(HttpStatus.NO_CONTENT, "COMMON204", "작업 성공");; private final HttpStatus httpStatus; // http 상태코드 private final String code; // 상태 코드 설명 diff --git a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java deleted file mode 100644 index 14ce25f..0000000 --- a/src/main/java/com/ureka/techpost/global/apiPayload/code/status/ErrorStatus.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.ureka.techpost.global.apiPayload.code.status; - -import com.ureka.techpost.global.apiPayload.code.BaseErrorCode; -import com.ureka.techpost.global.apiPayload.code.ErrorReasonDTO; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum ErrorStatus implements BaseErrorCode { - - // 가장 일반적인 응답 - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), - _BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400","잘못된 요청입니다."), - _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"COMMON401","인증이 필요합니다."), - _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - - // For test - TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "이거는 테스트"); - - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build(); - } - - -} diff --git a/src/main/java/com/ureka/techpost/global/config/AppConfig.java b/src/main/java/com/ureka/techpost/global/config/AppConfig.java new file mode 100644 index 0000000..ca87cc8 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/AppConfig.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * @file AppConfig.java + @author 김동혁 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 비밀번호 암호화를 위한 클래스입니다. + */ +@Configuration +public class AppConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java b/src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java new file mode 100644 index 0000000..41d21a7 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/QueryDSLConfig.java @@ -0,0 +1,18 @@ +package com.ureka.techpost.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDSLConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java index 2335346..acdf7be 100644 --- a/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java +++ b/src/main/java/com/ureka/techpost/global/config/SecurityConfig.java @@ -1,30 +1,106 @@ package com.ureka.techpost.global.config; +import com.ureka.techpost.domain.auth.handler.CustomAccessDeniedHandler; +import com.ureka.techpost.domain.auth.handler.CustomAuthenticationEntryPoint; +import com.ureka.techpost.domain.auth.handler.CustomAuthenticationFailureHandler; +import com.ureka.techpost.domain.auth.handler.OAuth2LoginSuccessHandler; +import com.ureka.techpost.domain.auth.jwt.JwtAuthenticationFilter; +import com.ureka.techpost.domain.auth.jwt.JwtUtil; +import com.ureka.techpost.domain.auth.service.CustomOAuth2UserService; +import com.ureka.techpost.domain.auth.service.TokenService; +import com.ureka.techpost.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.Collections; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final TokenService tokenService; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + + static final String[] WHITE_LIST = {"/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/health", + "/", "/login", "/signup", "/css/**", "/js/**", "/oauth2/**", + "/api/auth/**", + "/connect/**" + }; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, + CustomAuthenticationEntryPoint authenticationEntryPoint, + CustomAuthenticationFailureHandler authenticationFailureHandler, + CustomAccessDeniedHandler AccessDeniedHandler) throws Exception { http - .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/health" - ).permitAll() - .anyRequest().permitAll() + .requestMatchers(WHITE_LIST).permitAll() + .anyRequest().authenticated() + ) + + .exceptionHandling(e -> e + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(AccessDeniedHandler) ) - .httpBasic(Customizer.withDefaults()) - .formLogin(login -> login.disable()); + + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) + .failureHandler(authenticationFailureHandler) + ) + + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userRepository, tokenService), UsernamePasswordAuthenticationFilter.class); + return http.build(); } diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java new file mode 100644 index 0000000..d349da1 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompChannelInterceptor.java @@ -0,0 +1,93 @@ +/** + * @file StompChannelInterceptor.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 WebSocket 사용시 사용자 인증하는 클래스입니다. + */ + +package com.ureka.techpost.global.config.websocket; + +import com.ureka.techpost.domain.auth.dto.CustomUserDetails; +import com.ureka.techpost.domain.auth.service.CustomUserDetailsService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +// STOMP jwt 인증 처리 +// STOMP에서 클라이언트가 연결 요청시 JWT의 유효성을 검증하는 역할 +// 웹소켓으로 채팅 서버에 접속하려는 클라이언트가 보내는 연결메시지 (CONNECT)를 가로채서 메시지에 포함된 JWT 토큰이 유효한지 확인하는 보안설정 코드 +@Component +@RequiredArgsConstructor +public class StompChannelInterceptor implements ChannelInterceptor { + + // application.yaml에 정의된 jwt.secretKey 값을 가져와 필드에 주입 + @Value("${jwt.secret}") + private String secretKey; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + private final CustomUserDetailsService userDetailsService; + + // connect, subscribe, disconnet 하기 전에 preSend()를 무조건 거친다 + // 클라이언트로부터 메시지가 채널로 전송되기 직전에 이 메서드 호출됨 + @Override + public Message preSend(Message message, MessageChannel channel) { + + final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if(StompCommand.CONNECT == accessor.getCommand()) { + String bearerToken = accessor.getFirstNativeHeader("Authorization"); + String token = bearerToken.substring(7); + + // 토큰 검증 + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + String username = claims.getSubject(); + + CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username); + + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + accessor.setUser(authentication); + + } + + // 사용자가 채팅방의 참여자인지 검증 + if(StompCommand.SUBSCRIBE == accessor.getCommand()) { + System.out.println("subscribe 검증"); + String bearerToken = accessor.getFirstNativeHeader("Authorization"); + String token = bearerToken.substring(7); + + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + String email = claims.getSubject(); + String roomId = accessor.getDestination().split("/")[2]; + } + return message; + } + + + +} diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java new file mode 100644 index 0000000..da4828e --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompEventListener.java @@ -0,0 +1,45 @@ +/** + * @file StompEventLister.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 WebSocket 세션 관리 클래스입니다. + */ + +package com.ureka.techpost.global.config.websocket; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; + +// connection 객체 관리 +// 실시간 서버에서 문제 => 연결 객체 많아져서 서버 과부화되는 것 => 적절한 제거 필요함 + +// 스프링과 STOMP는 세션 관리를 자동 (내부적)으로 처리 +// 연결 / 해제 이벤트 기록, 연결된 세션 수를 실시간으로 확인할 목적으로 EventListener 생성 +// 로그 & 디버깅 목적 +@Component +public class StompEventListener { + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @EventListener + public void connectHandle(SessionConnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.add(accessor.getSessionId()); // 세션 생성 + + System.out.println("connect session ID " + accessor.getSessionId()); + System.out.println("total session : " + sessions.size()); + } + + @EventListener + public void disconnectHandle(SessionConnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.remove(accessor.getSessionId()); // 세션 삭제 + + System.out.println("disconnect session ID " + accessor.getSessionId()); + System.out.println("total session : " + sessions.size()); + } +} diff --git a/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java new file mode 100644 index 0000000..1700c77 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/config/websocket/StompWebSocketConfig.java @@ -0,0 +1,54 @@ +/** + * @file StompWebSocketConfig.java + @author 이재, 강승우 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 WebSocket 사용 설정 클래스입니다. + */ + +package com.ureka.techpost.global.config.websocket; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +// STOMP 사용해 메시지 브로커 설정 +@Configuration +@RequiredArgsConstructor +@EnableWebSocketMessageBroker // STOMP 전용 +public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final StompChannelInterceptor stompChannelInterceptor; + + // WebSocket 엔드포인트 등록 : 클라이언트가 연결할 수 있는 WebSocket 엔드포인트 정의 + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 클라이언트가 WebSocket에 연결하기 위한 엔드포인트를 "/connect"로 설정 + registry.addEndpoint("/connect") + // 클라이언트의 origin을 명시적으로 지정 + .setAllowedOrigins("http://localhost:3000") + // WebSocket을 지원하지 않는 브라우저에서도 SockJS를 통해 WebSocket 기능 사용 가능하도록 + .withSockJS(); + } + + // 메시지 브로커 구성 : 클라 - 서버 간의 메시지 라우팅 관리 + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 발행 (publish) : /publish/1 형태로 메시지 발행해야 함을 설정 + // /publish로 시작하는 url 패턴으로 메시지 발행되면 @Controller 객체의 @MessageMapping 메서드로 라우팅 + registry.setApplicationDestinationPrefixes("/publish"); + + // 수신 (subscribe) : /topic/1 형태로 메시지 수신해야 함을 설정 + registry.enableSimpleBroker("/topic"); + } + + // 웹소켓 요청 (Connect, subscribe, disconnect) 등의 요청 시에는 http reader 등 http 메시지를 넣어올 수 있고, + // 이를 interceptor를 통해 가로채 토큰 등을 검증할 수 있음 + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompChannelInterceptor); + } +} diff --git a/src/main/java/com/ureka/techpost/global/entity/BaseEntity.java b/src/main/java/com/ureka/techpost/global/entity/BaseEntity.java new file mode 100644 index 0000000..0a6f91b --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/entity/BaseEntity.java @@ -0,0 +1,32 @@ +package com.ureka.techpost.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * @file BaseEntity.java + @author 김동혁 + @version 1.0 + @since 2025-12-08 + @description 이 파일은 생성/수정 시간을 자동으로 관리하는 Base Entity 클래스입니다. + */ +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/ureka/techpost/global/exception/CustomException.java b/src/main/java/com/ureka/techpost/global/exception/CustomException.java new file mode 100644 index 0000000..4766848 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/CustomException.java @@ -0,0 +1,22 @@ +package com.ureka.techpost.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + private String info; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String info) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.info = info; + } +} diff --git a/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java b/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java new file mode 100644 index 0000000..9912b64 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/ErrorCode.java @@ -0,0 +1,32 @@ +package com.ureka.techpost.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + // 예시 + //Post + POST_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 게시글을 찾을 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND , "해당 댓글을 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."), + USER_NOT_MATCH(HttpStatus.FORBIDDEN, "수정 및 삭제 권한이 없습니다."), + + //Auth + USER_ALREADY_EXISTS(HttpStatus.CONFLICT,"이미 가입 되어있는 회원 입니다."),//409 + ACCESS_TOKEN_MISSING(HttpStatus.UNAUTHORIZED,"액세스 토큰이 없습니다."), + ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "만료된 액세스 토큰입니다."), + REFRESH_TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 없습니다."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "만료된 리프레시 토큰입니다."), + INVALID_TOKEN_CATEGORY(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰 타입입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "DB에 존재하지 않는 리프레시 토큰입니다.");//401 + + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java b/src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java new file mode 100644 index 0000000..5e98726 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/ErrorResponse.java @@ -0,0 +1,29 @@ +package com.ureka.techpost.global.exception; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@Getter +@Builder +public class ErrorResponse { + + private final HttpStatus status; + private final String code; + private final String message; + + public static ResponseEntity fromException(CustomException e) { + String message = e.getErrorCode().getMessage(); + if (e.getInfo() != null) { + message += " " + e.getInfo(); + } + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.builder() + .status(e.getErrorCode().getStatus()) + .code(e.getErrorCode().name()) + .message(message) + .build()); + } +} diff --git a/src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java b/src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d1f2558 --- /dev/null +++ b/src/main/java/com/ureka/techpost/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,26 @@ +package com.ureka.techpost.global.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + return ErrorResponse.fromException(e); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .code("INTERNAL_SERVER_ERROR") + .message("서버 내부 오류가 발생했습니다.") + .build()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4d57d1b --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,6 @@ +spring: + profiles: + active: dev # 기본은 dev + + application: + name: techpost diff --git a/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java b/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java new file mode 100644 index 0000000..13e65fb --- /dev/null +++ b/src/test/java/com/ureka/techpost/domain/auth/dto/SignupDtoValidationTest.java @@ -0,0 +1,67 @@ +package com.ureka.techpost.domain.auth.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import jakarta.validation.Validator; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SignupDtoValidationTest { + private final Validator validator = + Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void signup_username_blank_then_violation() { + SignupDto dto = new SignupDto(); + dto.setUsername(""); + dto.setPassword("password1"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertFalse(violations.isEmpty()); + } + + @Test + void signup_valid_then_no_violation() { + SignupDto dto = new SignupDto(); + dto.setUsername("testuser"); + dto.setPassword("password1"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertTrue(violations.isEmpty()); + } + @Test + void signup_shortPassword() { + SignupDto dto = new SignupDto(); + dto.setUsername("testuser"); + dto.setPassword("pass"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertFalse(violations.isEmpty()); + } + + @Test + void signup_shortUsername() { + SignupDto dto = new SignupDto(); + dto.setUsername("tes"); + dto.setPassword("pass"); + dto.setName("홍길동"); + + Set> violations = + validator.validate(dto); + + assertFalse(violations.isEmpty()); + } +} diff --git a/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java b/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java new file mode 100644 index 0000000..6a135da --- /dev/null +++ b/src/test/java/com/ureka/techpost/domain/auth/handler/CustomHandlersTest.java @@ -0,0 +1,77 @@ +package com.ureka.techpost.domain.auth.handler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomHandlersTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + // AccessDeniedHandler: 권한 부족 시 403 코드, JSON 응답 형식(status/code/message)을 반환하는지 검증 + void accessDeniedHandler_returnsForbiddenJson() throws Exception { + var handler = new CustomAccessDeniedHandler(new ObjectMapper()); + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + handler.handle(request, response, new AccessDeniedException("denied")); + + // 기대: 권한 부족 시 HTTP 403 + assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus()); + // 기대: JSON UTF-8 응답 헤더 + assertEquals("application/json;charset=UTF-8", response.getContentType()); + // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 + JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); + assertEquals("FORBIDDEN", body.get("status").asText()); + assertEquals("ACCESS_DENIED", body.get("code").asText()); + assertFalse(body.get("message").asText().isBlank()); + } + + @Test + // AuthenticationEntryPoint: 인증되지 않은 요청에 401 코드와 JSON 응답을 반환하는지 검증 + void entryPoint_returnsUnauthorizedJson() throws Exception { + var entryPoint = new CustomAuthenticationEntryPoint(); + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, new AuthenticationException("auth fail") {}); + + // 기대: 인증 실패 시 HTTP 401 + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + // 기대: JSON UTF-8 응답 헤더 + assertEquals("application/json;charset=UTF-8", response.getContentType()); + // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 + JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); + assertEquals("UNAUTHORIZED", body.get("status").asText()); + assertEquals("AUTHENTICATION_FAILED", body.get("code").asText()); + assertFalse(body.get("message").asText().isBlank()); + } + + @Test + // AuthenticationFailureHandler: 로그인 실패 시 401 코드와 JSON 응답을 반환하는지 검증 + void failureHandler_returnsUnauthorizedJson() throws Exception { + var failureHandler = new CustomAuthenticationFailureHandler(); + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + failureHandler.onAuthenticationFailure(request, response, new AuthenticationException("login fail") {}); + + // 기대: 로그인 실패 시 HTTP 401 + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + // 기대: JSON UTF-8 응답 헤더 + assertEquals("application/json;charset=UTF-8", response.getContentType()); + // 기대: 바디의 status/code/message 필드가 존재하고 값이 비어 있지 않음 + JsonNode body = OBJECT_MAPPER.readTree(response.getContentAsString()); + assertEquals("UNAUTHORIZED", body.get("status").asText()); + assertEquals("LOGIN_FAILED", body.get("code").asText()); + assertFalse(body.get("message").asText().isBlank()); + } +}